import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; import { strokeMatches, Character, parseCharData, Positioner, UserStroke, } from './hanzi-writer'; import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; import type { Point, QuizOptions, CharacterJson } from './types'; import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, } from 'react'; import RNSvg, { Defs, ClipPath, Path, G, Line, PathProps, SvgProps, } from 'react-native-svg'; import LoadingIndicator from './components/LoadingIndicator'; import simplify from './simplify'; import { getPathString as getPathStringWorklet } from './geometry-worklet'; import { StrokeAnimator } from './components/StrokeAnimator'; import Animated, { Easing, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; import { usePromise } from './hooks/usePromise'; import AnimatedPath from './components/AnimatedPath'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { generateId } from './utils'; export const HanziWriterContext = createContext | null>(null); interface HanziWriterProps { writer: ReturnType; children: ReactNode; /** What to display while the character is loading */ loading?: React.ReactNode; /** What to display if there's an error */ error?: React.ReactNode; /** Container style */ style?: StyleProp; userStrokeProps?: PathProps; } export function HanziWriter(props: HanziWriterProps) { const containerStyle = [ styles.hanziWriterRoot, { width: props.writer.size, height: props.writer.size, maxHeight: props.writer.size, }, props.style, ]; return ( {props.children} ); } HanziWriter.Svg = HanziWriterSvg; HanziWriter.GridLines = HanziWriterGridLines; HanziWriter.Outline = HanziWriterOutline; HanziWriter.Character = HanziWriterCharacter; HanziWriter.QuizMistakeHighlighter = QuizMistakeHighlighter; HanziWriter.QuizStrokes = HanziWriterQuizStrokes; export async function defaultCharDataLoader( char: string ): Promise { return fetch( `https://cdn.jsdelivr.net/npm/hanzi-writer-data@2.0/${char}.json` ).then((res) => res.json()); } export function QuizMistakeHighlighter({ color = '#555', strokeDuration = 400, }) { const writer = useContext(HanziWriterContext)!; const quiz = writer.quiz.useStore((s) => s); const animationPath = writer.characterClass?.strokes[quiz.index]; const animationKey = useMemo((): string | null => { if ( typeof quiz.params?.showHintAfterMisses === 'boolean' && !quiz.params?.showHintAfterMisses ) { return null; } const showHintAfterMisses = quiz.params?.showHintAfterMisses || 3; const shouldAnimateStroke = (quiz.mistakes[quiz.index] || 0) >= showHintAfterMisses; if (shouldAnimateStroke) { return generateId(); } return null; }, [quiz.mistakes, quiz.index, quiz.params]); if (!quiz.active || !animationPath || !animationKey) { return null; } return ( { // Animate the stroke as soon as it's rendered ref?.animate({ duration: strokeDuration, onComplete: ref.reset }); }} strokeColor={color} strokeWidth={100} key={`mistake.${animationPath.strokeNum}.${animationKey}`} stroke={animationPath} /> ); } /** Animates all strokes */ export function CharacterAnimator({ color = '#555', radicalColor, }: { color?: string; radicalColor?: string; }) { const writer = useContext(HanziWriterContext)!; const animationState = writer.animator.useStore((s) => s); if (animationState.state !== 'playing' || !writer.characterClass) { return null; } return ( <> {writer.characterClass.strokes.map((stroke, idx) => { const isLast = idx === writer.characterClass!.strokes.length - 1; const colorToUse = stroke.isInRadical ? radicalColor : color; return ( { // Animate the stroke as soon as it's rendered ref?.animate({ duration: animationState.strokeDuration, delay: idx * animationState.delayBetweenStrokes + animationState.strokeDuration * idx, onComplete: isLast ? writer.animator.onAnimationComplete : undefined, }); }} strokeColor={colorToUse || color} strokeWidth={100} key={`char.${stroke.strokeNum}`} stroke={stroke} /> ); })} ); } interface CharacterLoaderProps { /** What to display while the character is loading */ loading?: React.ReactNode; /** What to display if there's an error */ error?: React.ReactNode; /** Resolved character data */ children: ReactNode; } export function CharacterLoader({ loading, error, children, }: CharacterLoaderProps) { const writer = useContext(HanziWriterContext)!; switch (writer.characterState.status) { case 'idle': return null; case 'rejected': return error || Error loading character; case 'pending': return loading || ; default: return <>{children}; } } // TRANSFORM is computed per-writer from the Positioner (maps internal char coords -> SVG coords) function getWriterTransform(writer: ReturnType) { const p = writer.positioner; // map: x_e = internal_x * scale + xOffset // y_e = height - yOffset - internal_y * scale // which is equivalent to: translate(xOffset, height - yOffset) scale(scale, -scale) return `translate(${p.xOffset}, ${p.height - p.yOffset}) scale(${ p.scale }, ${-p.scale})`; } export type HanziWriterAnimationState = | 'playing' | 'paused' | 'stopped' | 'cancelled'; /** This component handles everything to do with the user's gestures on the writer element */ export function UserStrokeGesture(props: PathProps) { const writer = useContext(HanziWriterContext)!; const points = useSharedValue([]); const active = writer.quiz.useStore((state) => state.active); const fade = useSharedValue(1); const check = useCallback(() => { const simplifiedPoints = simplify(points.value, 1); writer.quiz.check(simplifiedPoints); // Fade out the user's stroke fade.value = withTiming(0, { duration: 200 }, (finished) => { // only reset the points if the animation finished if (finished) { points.value = []; } }); }, [writer.quiz.check]); const panGesture = useMemo( () => Gesture.Pan() .enabled(active) .minDistance(0) .onBegin((event) => { fade.value = 1; points.value = [{ x: event.x, y: event.y }]; }) .onUpdate((event) => { points.value = [...points.value, { x: event.x, y: event.y }]; }) .onEnd(() => { if (points.value.length > 0) { scheduleOnRN(check); } }) .onFinalize((_event, success) => { // Pan never activated (e.g. tap without drag) — clear the preview point if (!success) { points.value = []; } }), [active, check] ); const animatedPathProps = useAnimatedProps(() => ({ d: getPathStringWorklet(points.value), opacity: fade.value, })); return ( ); } interface HanziWriterSvgProps extends SvgProps { children?: ReactNode; } function HanziWriterSvg({ children, ...rest }: HanziWriterSvgProps) { const writer = useContext(HanziWriterContext)!; return ( {writer.characterClass?.strokes.map((stroke) => ( ))} {children || null} ); } function HanziWriterQuizStrokes(props: { color?: string; radicalColor?: string; }) { const { color = '#555' } = props; const writer = useContext(HanziWriterContext)!; const quizIndex = writer.quiz.useStore((state) => state.index); const correctPaths = writer.characterClass?.strokes.filter((_, i) => quizIndex > i) || []; return ( {correctPaths.map((stroke) => { const colorToUse = stroke.isInRadical ? props.radicalColor : color; return ( ); })} ); } function HanziWriterCharacter(props: { color?: string; radicalColor?: string; }) { const writer = useContext(HanziWriterContext)!; const { characterClass } = writer; const quizActive = writer.quiz.useStore((state) => state.active); const isAnimating = writer.animator.useStore( (state) => state.state === 'playing' ); const color = props.color || '#555'; const showCharStrokes = !quizActive && !isAnimating; return ( <> {showCharStrokes && ( {characterClass?.strokes.map((stroke) => { const fillColor = stroke.isInRadical ? props.radicalColor : color; return ( ); })} )} ); } function HanziWriterOutline(props: { color?: string }) { const writer = useContext(HanziWriterContext); const color = props.color || '#ededed'; if (!writer) { return null; } return ( {writer?.characterClass?.strokes.map((stroke) => { return ( ); })} ); } function PathFadeIn(props: PathProps) { const opacity = useSharedValue(0); useEffect(() => { opacity.value = withTiming(1, { duration: 300, easing: Easing.linear }); }, [opacity]); const animatedProps = useAnimatedProps(() => { return { // It's possible that opacity.value may be negative on Android (even on linear easing timing) // which causes a brief flicker of the stroke. Clamp it to 0-1 to avoid this. fillOpacity: Math.max(0, Math.min(1, opacity.value)), }; }); return ; } export function HanziWriterGridLines(props: { color?: string; width?: number; }) { const writer = useContext(HanziWriterContext)!; const { color = '#DDD', width = 2 } = props; const mid = writer.size / 2; return ( <> ); } const styles = StyleSheet.create({ hanziWriterRoot: { height: 300, width: 300, maxHeight: 300, }, userStrokeLayer: { zIndex: 2, }, transparentSvg: { backgroundColor: 'transparent', }, }); function createStoreApi(initialState: T) { let state = initialState; const subscribers = new Set<() => void>(); return { subscribe: (listener: () => void) => { subscribers.add(listener); return () => { subscribers.delete(listener); }; }, getState: () => state, setState(newState: Partial) { state = { ...state, ...newState }; subscribers.forEach((listener) => listener()); }, }; } type AvailableQuizOptions = Omit; type StartQuizParams = Partial; interface CharacterAnimatorState { character: string; state: 'playing' | 'stopped'; animationKey: string | null; onComplete: (() => void) | null; delayBetweenStrokes: number; strokeDuration: number; } const useCharacterAnimator = (params: { character: string }) => { const animationKeyRef = useRef(''); const characterAnimatorStore = useMemo(() => { return createStoreApi({ character: params.character, state: 'stopped', animationKey: null, onComplete: null, delayBetweenStrokes: 1500, strokeDuration: 400, }); }, [params.character]); return { useStore( selector: (state: CharacterAnimatorState) => T, equalityFn?: any ): T { return useStore(characterAnimatorStore, selector, equalityFn); }, store: characterAnimatorStore, animateCharacter(params?: { /** In milliseconds. Default: 400 */ strokeDuration?: number; /** In milliseconds. Default: 1500 */ delayBetweenStrokes?: number; onComplete?: () => void; }) { const { strokeDuration = 400, delayBetweenStrokes = 1500 } = params || {}; animationKeyRef.current = generateId(); characterAnimatorStore.setState({ state: 'playing', animationKey: animationKeyRef.current, onComplete: params?.onComplete, delayBetweenStrokes, strokeDuration, }); }, cancelAnimation() { characterAnimatorStore.setState({ state: 'stopped' }); }, onAnimationComplete: () => { const { animationKey } = characterAnimatorStore.getState(); // Avoid race conditions if (animationKeyRef.current === animationKey) { characterAnimatorStore.getState().onComplete?.(); characterAnimatorStore.setState({ state: 'stopped' }); } }, }; }; type QuizState = | { readonly active: false; character: string; index: number; mistakes: Record; params: null; } | { readonly active: true; character: string; index: number; mistakes: Record; params: StartQuizParams; }; const createQuizStore = (character: string) => { const api = createStoreApi({ character, active: false, mistakes: {}, index: 0, params: null, }); return { ...api, next() { api.setState({ index: api.getState().index + 1 }); }, }; }; function useStore( api: ReturnType, selector: (state: any) => any, equalityFn: any ) { return useSyncExternalStoreWithSelector( api.subscribe, api.getState, api.getState, selector, equalityFn ); } const useQuiz = (params: { characterClass: Character | null; character: string; cancelAnimation(): void; positioner: Positioner; }) => { const { cancelAnimation, character, characterClass, positioner } = params; const quizStore = useMemo(() => createQuizStore(character), [character]); const check = useCallback( (simplifiedPoints: Point[]) => { if (!characterClass) { return; } const { index, mistakes, params } = quizStore.getState(); const [firstExternalPoint, ...restExternalPoints] = simplifiedPoints; const firstPoint = positioner.convertExternalPoint(firstExternalPoint); const userStroke = new UserStroke(index, firstPoint, firstExternalPoint); for (const externalPoint of restExternalPoints) { const offset = positioner.convertExternalPoint(externalPoint); userStroke.appendPoint(offset, externalPoint); } const { isMatch, meta } = strokeMatches( userStroke, characterClass, index, { leniency: params?.leniency || 1.2, } ); const isAccepted = isMatch || (meta.isStrokeBackwards && params?.acceptBackwardsStrokes); if (!isAccepted) { const numMistakes = (mistakes[index] || 0) + 1; quizStore.setState({ mistakes: { ...mistakes, [index]: numMistakes, }, }); quizStore.getState().params?.onMistake?.({ character, drawnPath: { pathString: '', points: userStroke.points, }, isBackwards: meta.isStrokeBackwards, mistakesOnStroke: numMistakes, strokeNum: index, strokesRemaining: characterClass.strokes.length - index, totalMistakes: Object.keys(mistakes).length, }); } else { const active = index < characterClass.strokes.length - 1; if (!active) { params?.onComplete?.({ totalMistakes: Object.keys(mistakes).length, character: characterClass.symbol, }); } quizStore.setState({ index: active ? index + 1 : 0, active }); } }, [character, characterClass, quizStore] ); return { store: quizStore, useStore(selector: (state: QuizState) => T, equalityFn?: any): T { return useStore(quizStore, selector, equalityFn); }, check, start: (params: StartQuizParams) => { if (!characterClass) { console.warn("Can't start quiz, character not loaded yet"); return; } // Cancel any running animations cancelAnimation(); // Start from an index between 0 and the number of strokes const index = Math.max( Math.min( params.quizStartStrokeNum || 0, characterClass.strokes.length - 1 ), 0 ); quizStore.setState({ active: true, index, mistakes: {}, params, }); }, stop: () => { quizStore.setState({ active: false, index: 0, mistakes: {}, params: null, }); }, }; }; export const useHanziWriter = (params: { character: string; loader?: (char: string) => Promise | CharacterJson; /** Optional widget size in px (default 300) */ size?: number; /** Optional padding for the Positioner (default 0) */ padding?: number; }) => { const { character } = params; const size = params.size ?? 300; const padding = params.padding ?? 0; const promise = useCallback(async () => { const loader = params.loader || defaultCharDataLoader; const value = loader(character); if (value instanceof Promise) { return value.then((res) => parseCharData(character, res)); } return parseCharData(character, value); // eslint-disable-next-line react-hooks/exhaustive-deps }, [character]); const { state: characterState, refetch } = usePromise({ cacheKey: `char.${character}`, promiseFn: promise, }); const animator = useCharacterAnimator({ character }); const characterClass = characterState.status === 'resolved' ? characterState.data : null; const positioner = useMemo( () => new Positioner({ height: size, width: size, padding }), [size, padding] ); const quiz = useQuiz({ character, characterClass, cancelAnimation: animator.cancelAnimation, positioner, }); return { animator, quiz, characterState, characterClass, refetch, size, positioner, }; };