Skip to content

Commit 1cc38ba

Browse files
authored
Add text counter (#86)
1 parent 674d07d commit 1cc38ba

7 files changed

Lines changed: 157 additions & 10 deletions

File tree

frontend/public/locales/en/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"acceptAiSuggestion": "Accept and create",
3131
"rejectAiSuggestion": "Reject and reprompt"
3232
},
33+
"editorTextCounter": {
34+
"title": "Characters Limitation",
35+
"description": "Exceed the maximum characters"
36+
},
3337
"practiceComplete": {
3438
"cardsCorrect": "You got {{correct}} out of {{total}} cards correct",
3539
"title": "Practice Complete"

frontend/public/locales/es/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"acceptAiSuggestion": "Aceptar y crear",
3131
"rejectAiSuggestion": "Rechazar y reintentar prompt"
3232
},
33+
"editorTextCounter": {
34+
"title": "Limitación de caracteres",
35+
"description": "Superar el máximo de caracteres"
36+
},
3337
"practiceComplete": {
3438
"cardsCorrect": "Has acertado {{correct}} de {{total}} tarjetas",
3539
"title": "Práctica Completa"

frontend/public/locales/nl/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"acceptAiSuggestion": "Accepteren en aanmaken",
3131
"rejectAiSuggestion": "Afwijzen en opnieuw vragen"
3232
},
33+
"editorTextCounter": {
34+
"title": "Beperking van tekens",
35+
"description": "Overschrijd het maximum aantal tekens"
36+
},
3337
"practiceComplete": {
3438
"cardsCorrect": "Je hebt {{correct}} van de {{total}} kaarten goed",
3539
"title": "Oefening Voltooid"

frontend/src/components/cards/CardEditor.tsx

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import RichTextEditor from '@/components/commonUI/RichText/RichTextEditor'
2+
import useTextCounter from '@/hooks/useTextCounter'
23
import { Box } from '@chakra-ui/react'
34
import type { Editor } from '@tiptap/react'
5+
import { useCallback, useMemo, useState } from 'react'
6+
import TextCounter from '../commonUI/TextCounter'
47

58
export interface CardEditorProps {
69
side: 'front' | 'back'
@@ -10,16 +13,40 @@ export interface CardEditorProps {
1013
}
1114

1215
export default function CardEditor({ side, isFlipped, frontEditor, backEditor }: CardEditorProps) {
13-
const commonBoxStyles = {
14-
position: 'absolute' as const,
15-
width: '100%',
16-
height: '100%',
17-
backfaceVisibility: 'hidden' as const,
18-
borderRadius: 'lg',
19-
borderWidth: '1px',
20-
borderColor: 'bg.200',
21-
cursor: 'text',
22-
}
16+
const commonBoxStyles = useMemo(
17+
() => ({
18+
position: 'absolute' as const,
19+
width: '100%',
20+
height: '100%',
21+
backfaceVisibility: 'hidden' as const,
22+
borderRadius: 'lg',
23+
borderWidth: '1px',
24+
borderColor: 'bg.200',
25+
cursor: 'text',
26+
}),
27+
[],
28+
)
29+
30+
const [frontTextLength, setFrontTextLength] = useState(0)
31+
const [backTextLength, setBackTextLength] = useState(0)
32+
33+
const memoizedSetFrontTextLength = useCallback(setFrontTextLength, [])
34+
const memoizedSetBackTextLength = useCallback(setBackTextLength, [])
35+
const noopSetter = useCallback(() => {}, [])
36+
37+
useTextCounter(
38+
side === 'front' ? frontEditor : null,
39+
side === 'front' ? memoizedSetFrontTextLength : noopSetter,
40+
)
41+
useTextCounter(
42+
side === 'back' ? backEditor : null,
43+
side === 'back' ? memoizedSetBackTextLength : noopSetter,
44+
)
45+
46+
const currentTextLength = useMemo(
47+
() => (side === 'front' ? frontTextLength : backTextLength),
48+
[side, frontTextLength, backTextLength],
49+
)
2350

2451
return (
2552
<Box
@@ -33,6 +60,7 @@ export default function CardEditor({ side, isFlipped, frontEditor, backEditor }:
3360
>
3461
<Box {...commonBoxStyles} bg="bg.50">
3562
{side === 'front' && frontEditor && <RichTextEditor editor={frontEditor} />}
63+
{side === 'front' && <TextCounter textLength={currentTextLength} />}
3664
</Box>
3765

3866
<Box
@@ -42,6 +70,7 @@ export default function CardEditor({ side, isFlipped, frontEditor, backEditor }:
4270
visibility={isFlipped ? 'visible' : 'hidden'}
4371
>
4472
{side === 'back' && backEditor && <RichTextEditor editor={backEditor} />}
73+
{side === 'back' && <TextCounter textLength={currentTextLength} />}
4574
</Box>
4675
</Box>
4776
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { MAX_CHARACTERS, WARNING_THRESHOLD } from '@/utils/text'
2+
import { Text } from '@chakra-ui/react'
3+
4+
interface TextCounterProps {
5+
textLength: number
6+
}
7+
8+
const getTextColor = (currentLength: number) => {
9+
const warningLimit = MAX_CHARACTERS - WARNING_THRESHOLD
10+
if (currentLength > warningLimit) {
11+
return 'red.500'
12+
}
13+
return 'gray.500'
14+
}
15+
16+
function TextCounter({ textLength }: TextCounterProps) {
17+
return (
18+
<Text position="absolute" bottom="2" right="2" fontSize="sm" color={getTextColor(textLength)}>
19+
{textLength}/{MAX_CHARACTERS}
20+
</Text>
21+
)
22+
}
23+
24+
export default TextCounter
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { toaster } from '@/components/ui/toaster'
2+
import { MAX_CHARACTERS } from '@/utils/text'
3+
import type { Editor } from '@tiptap/react'
4+
import { useCallback, useEffect, useRef } from 'react'
5+
import { useTranslation } from 'react-i18next'
6+
7+
function useTextCounter(editor: Editor | null, setTextLength: (length: number) => void) {
8+
const { t } = useTranslation()
9+
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
10+
const hasShownToastRef = useRef(false)
11+
12+
const handleTextChange = useCallback(
13+
(currentEditor: Editor) => {
14+
const currentText = currentEditor.getText()
15+
const textLength = currentText.length
16+
17+
if (textLength > MAX_CHARACTERS) {
18+
const truncatedText = currentText.substring(0, MAX_CHARACTERS)
19+
currentEditor.chain().focus().setContent(truncatedText).run()
20+
if (!hasShownToastRef.current) {
21+
hasShownToastRef.current = true
22+
toaster.create({
23+
title: t('components.editorTextCounter.title'),
24+
description: t('components.editorTextCounter.description'),
25+
type: 'info',
26+
})
27+
28+
timeoutRef.current = setTimeout(() => {
29+
hasShownToastRef.current = false
30+
}, 3000)
31+
}
32+
33+
setTextLength(MAX_CHARACTERS)
34+
} else {
35+
hasShownToastRef.current = false
36+
setTextLength(textLength)
37+
}
38+
},
39+
[setTextLength, t],
40+
)
41+
42+
useEffect(() => {
43+
if (!editor) {
44+
setTextLength(0)
45+
return
46+
}
47+
48+
const handleUpdate = ({ editor: currentEditor }: { editor: Editor }) => {
49+
if (currentEditor && !currentEditor.isDestroyed) {
50+
handleTextChange(currentEditor)
51+
}
52+
}
53+
54+
editor.on('update', handleUpdate)
55+
56+
handleTextChange(editor)
57+
58+
return () => {
59+
if (timeoutRef.current) {
60+
clearTimeout(timeoutRef.current)
61+
timeoutRef.current = null
62+
}
63+
64+
if (editor && !editor.isDestroyed) {
65+
editor.off('update', handleUpdate)
66+
}
67+
}
68+
}, [editor, handleTextChange, setTextLength])
69+
70+
useEffect(() => {
71+
return () => {
72+
if (timeoutRef.current) {
73+
clearTimeout(timeoutRef.current)
74+
}
75+
}
76+
}, [])
77+
}
78+
79+
export default useTextCounter

frontend/src/utils/text.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@ export function stripHtml(html: string) {
22
const doc = new DOMParser().parseFromString(html, 'text/html')
33
return doc.body.textContent || ''
44
}
5+
6+
export const MAX_CHARACTERS = 3000
7+
export const WARNING_THRESHOLD = 20

0 commit comments

Comments
 (0)