diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json
index 304e480..c833933 100644
--- a/frontend/public/locales/en/translation.json
+++ b/frontend/public/locales/en/translation.json
@@ -30,6 +30,10 @@
"acceptAiSuggestion": "Accept and create",
"rejectAiSuggestion": "Reject and reprompt"
},
+ "editorTextCounter": {
+ "title": "Characters Limitation",
+ "description": "Exceed the maximum characters"
+ },
"practiceComplete": {
"cardsCorrect": "You got {{correct}} out of {{total}} cards correct",
"title": "Practice Complete"
diff --git a/frontend/public/locales/es/translation.json b/frontend/public/locales/es/translation.json
index 62f764e..d60184d 100644
--- a/frontend/public/locales/es/translation.json
+++ b/frontend/public/locales/es/translation.json
@@ -30,6 +30,10 @@
"acceptAiSuggestion": "Aceptar y crear",
"rejectAiSuggestion": "Rechazar y reintentar prompt"
},
+ "editorTextCounter": {
+ "title": "Limitación de caracteres",
+ "description": "Superar el máximo de caracteres"
+ },
"practiceComplete": {
"cardsCorrect": "Has acertado {{correct}} de {{total}} tarjetas",
"title": "Práctica Completa"
diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json
index 39a694c..f6eb26d 100644
--- a/frontend/public/locales/nl/translation.json
+++ b/frontend/public/locales/nl/translation.json
@@ -30,6 +30,10 @@
"acceptAiSuggestion": "Accepteren en aanmaken",
"rejectAiSuggestion": "Afwijzen en opnieuw vragen"
},
+ "editorTextCounter": {
+ "title": "Beperking van tekens",
+ "description": "Overschrijd het maximum aantal tekens"
+ },
"practiceComplete": {
"cardsCorrect": "Je hebt {{correct}} van de {{total}} kaarten goed",
"title": "Oefening Voltooid"
diff --git a/frontend/src/components/cards/CardEditor.tsx b/frontend/src/components/cards/CardEditor.tsx
index 47617a3..9121f70 100644
--- a/frontend/src/components/cards/CardEditor.tsx
+++ b/frontend/src/components/cards/CardEditor.tsx
@@ -1,6 +1,9 @@
import RichTextEditor from '@/components/commonUI/RichText/RichTextEditor'
+import useTextCounter from '@/hooks/useTextCounter'
import { Box } from '@chakra-ui/react'
import type { Editor } from '@tiptap/react'
+import { useCallback, useMemo, useState } from 'react'
+import TextCounter from '../commonUI/TextCounter'
export interface CardEditorProps {
side: 'front' | 'back'
@@ -10,16 +13,40 @@ export interface CardEditorProps {
}
export default function CardEditor({ side, isFlipped, frontEditor, backEditor }: CardEditorProps) {
- const commonBoxStyles = {
- position: 'absolute' as const,
- width: '100%',
- height: '100%',
- backfaceVisibility: 'hidden' as const,
- borderRadius: 'lg',
- borderWidth: '1px',
- borderColor: 'bg.200',
- cursor: 'text',
- }
+ const commonBoxStyles = useMemo(
+ () => ({
+ position: 'absolute' as const,
+ width: '100%',
+ height: '100%',
+ backfaceVisibility: 'hidden' as const,
+ borderRadius: 'lg',
+ borderWidth: '1px',
+ borderColor: 'bg.200',
+ cursor: 'text',
+ }),
+ [],
+ )
+
+ const [frontTextLength, setFrontTextLength] = useState(0)
+ const [backTextLength, setBackTextLength] = useState(0)
+
+ const memoizedSetFrontTextLength = useCallback(setFrontTextLength, [])
+ const memoizedSetBackTextLength = useCallback(setBackTextLength, [])
+ const noopSetter = useCallback(() => {}, [])
+
+ useTextCounter(
+ side === 'front' ? frontEditor : null,
+ side === 'front' ? memoizedSetFrontTextLength : noopSetter,
+ )
+ useTextCounter(
+ side === 'back' ? backEditor : null,
+ side === 'back' ? memoizedSetBackTextLength : noopSetter,
+ )
+
+ const currentTextLength = useMemo(
+ () => (side === 'front' ? frontTextLength : backTextLength),
+ [side, frontTextLength, backTextLength],
+ )
return (
{side === 'front' && frontEditor && }
+ {side === 'front' && }
{side === 'back' && backEditor && }
+ {side === 'back' && }
)
diff --git a/frontend/src/components/commonUI/TextCounter.tsx b/frontend/src/components/commonUI/TextCounter.tsx
new file mode 100644
index 0000000..047492d
--- /dev/null
+++ b/frontend/src/components/commonUI/TextCounter.tsx
@@ -0,0 +1,24 @@
+import { MAX_CHARACTERS, WARNING_THRESHOLD } from '@/utils/text'
+import { Text } from '@chakra-ui/react'
+
+interface TextCounterProps {
+ textLength: number
+}
+
+const getTextColor = (currentLength: number) => {
+ const warningLimit = MAX_CHARACTERS - WARNING_THRESHOLD
+ if (currentLength > warningLimit) {
+ return 'red.500'
+ }
+ return 'gray.500'
+}
+
+function TextCounter({ textLength }: TextCounterProps) {
+ return (
+
+ {textLength}/{MAX_CHARACTERS}
+
+ )
+}
+
+export default TextCounter
diff --git a/frontend/src/hooks/useTextCounter.tsx b/frontend/src/hooks/useTextCounter.tsx
new file mode 100644
index 0000000..3135cdc
--- /dev/null
+++ b/frontend/src/hooks/useTextCounter.tsx
@@ -0,0 +1,79 @@
+import { toaster } from '@/components/ui/toaster'
+import { MAX_CHARACTERS } from '@/utils/text'
+import type { Editor } from '@tiptap/react'
+import { useCallback, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+
+function useTextCounter(editor: Editor | null, setTextLength: (length: number) => void) {
+ const { t } = useTranslation()
+ const timeoutRef = useRef(null)
+ const hasShownToastRef = useRef(false)
+
+ const handleTextChange = useCallback(
+ (currentEditor: Editor) => {
+ const currentText = currentEditor.getText()
+ const textLength = currentText.length
+
+ if (textLength > MAX_CHARACTERS) {
+ const truncatedText = currentText.substring(0, MAX_CHARACTERS)
+ currentEditor.chain().focus().setContent(truncatedText).run()
+ if (!hasShownToastRef.current) {
+ hasShownToastRef.current = true
+ toaster.create({
+ title: t('components.editorTextCounter.title'),
+ description: t('components.editorTextCounter.description'),
+ type: 'info',
+ })
+
+ timeoutRef.current = setTimeout(() => {
+ hasShownToastRef.current = false
+ }, 3000)
+ }
+
+ setTextLength(MAX_CHARACTERS)
+ } else {
+ hasShownToastRef.current = false
+ setTextLength(textLength)
+ }
+ },
+ [setTextLength, t],
+ )
+
+ useEffect(() => {
+ if (!editor) {
+ setTextLength(0)
+ return
+ }
+
+ const handleUpdate = ({ editor: currentEditor }: { editor: Editor }) => {
+ if (currentEditor && !currentEditor.isDestroyed) {
+ handleTextChange(currentEditor)
+ }
+ }
+
+ editor.on('update', handleUpdate)
+
+ handleTextChange(editor)
+
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ timeoutRef.current = null
+ }
+
+ if (editor && !editor.isDestroyed) {
+ editor.off('update', handleUpdate)
+ }
+ }
+ }, [editor, handleTextChange, setTextLength])
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [])
+}
+
+export default useTextCounter
diff --git a/frontend/src/utils/text.ts b/frontend/src/utils/text.ts
index c7bae9a..b26df8f 100644
--- a/frontend/src/utils/text.ts
+++ b/frontend/src/utils/text.ts
@@ -2,3 +2,6 @@ export function stripHtml(html: string) {
const doc = new DOMParser().parseFromString(html, 'text/html')
return doc.body.textContent || ''
}
+
+export const MAX_CHARACTERS = 3000
+export const WARNING_THRESHOLD = 20