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