From e610a7fe69a9f6e0561b6d46d1d26e39b056900f Mon Sep 17 00:00:00 2001 From: "ryan.li" Date: Sun, 11 May 2025 22:02:13 +0800 Subject: [PATCH 1/8] feat-37: Add text counter --- frontend/src/components/cards/CardEditor.tsx | 11 +++++ .../src/components/commonUI/TextCounter.tsx | 24 +++++++++++ frontend/src/hooks/useTextCounter.tsx | 43 +++++++++++++++++++ frontend/src/utils/text.ts | 2 + 4 files changed, 80 insertions(+) create mode 100644 frontend/src/components/commonUI/TextCounter.tsx create mode 100644 frontend/src/hooks/useTextCounter.tsx diff --git a/frontend/src/components/cards/CardEditor.tsx b/frontend/src/components/cards/CardEditor.tsx index 47617a3..dd371ad 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 { useState } from 'react' +import TextCounter from '../commonUI/TextCounter' export interface CardEditorProps { side: 'front' | 'back' @@ -21,6 +24,12 @@ export default function CardEditor({ side, isFlipped, frontEditor, backEditor }: cursor: 'text', } + const [frontTextLength, setFrontTextLength] = useState(0) + const [backTextLength, setBackTextLength] = useState(0) + + useTextCounter(frontEditor, setFrontTextLength) + useTextCounter(backEditor, setBackTextLength) + 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..d11b101 --- /dev/null +++ b/frontend/src/components/commonUI/TextCounter.tsx @@ -0,0 +1,24 @@ +import { MAX_CHARACTERS } from '@/utils/text' +import { Text } from '@chakra-ui/react' + +interface TextCounterProps { + textLength: number +} + +const getTextColor = (currentLength: number) => { + const percentage = currentLength / MAX_CHARACTERS + if (percentage > 0.9) { + 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..218d2d8 --- /dev/null +++ b/frontend/src/hooks/useTextCounter.tsx @@ -0,0 +1,43 @@ +import { toaster } from '@/components/ui/toaster' +import { MAX_CHARACTERS } from '@/utils/text' +import type { Editor } from '@tiptap/react' +import { useCallback, useEffect } from 'react' + +function useTextCounter(editor: Editor | null, setTextLength: (length: number) => void) { + const handleTextChange = useCallback( + (currentEditor: Editor) => { + if (currentEditor) { + const currentText = currentEditor.getText() + setTextLength(currentText.length) + if (currentText.length > MAX_CHARACTERS) { + currentEditor.chain().undo().run() + toaster.create({ + title: 'Characters limitation', + description: 'Exceed the maximum characters', + type: 'info', + }) + } + } + }, + [setTextLength], + ) + + useEffect(() => { + if (editor) { + const handleUpdate = ({ editor: currentEditor }: { editor: Editor }) => { + handleTextChange(currentEditor) + } + editor.on('update', handleUpdate) + // Calculate when initialization + handleTextChange(editor) + + return () => { + editor.off('update', handleUpdate) + } + } + }, [editor, handleTextChange]) + + return +} + +export default useTextCounter diff --git a/frontend/src/utils/text.ts b/frontend/src/utils/text.ts index c7bae9a..90a111e 100644 --- a/frontend/src/utils/text.ts +++ b/frontend/src/utils/text.ts @@ -2,3 +2,5 @@ export function stripHtml(html: string) { const doc = new DOMParser().parseFromString(html, 'text/html') return doc.body.textContent || '' } + +export const MAX_CHARACTERS = 3000 From b59446127c0002f7745cb28dc18b1904e3f3217e Mon Sep 17 00:00:00 2001 From: "ryan.li" Date: Sun, 11 May 2025 22:11:42 +0800 Subject: [PATCH 2/8] Add corresponding translation --- frontend/public/locales/en/translation.json | 4 ++++ frontend/public/locales/es/translation.json | 4 ++++ frontend/public/locales/nl/translation.json | 4 ++++ frontend/src/hooks/useTextCounter.tsx | 12 ++++++++---- 4 files changed, 20 insertions(+), 4 deletions(-) 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/hooks/useTextCounter.tsx b/frontend/src/hooks/useTextCounter.tsx index 218d2d8..2b533bc 100644 --- a/frontend/src/hooks/useTextCounter.tsx +++ b/frontend/src/hooks/useTextCounter.tsx @@ -2,18 +2,22 @@ import { toaster } from '@/components/ui/toaster' import { MAX_CHARACTERS } from '@/utils/text' import type { Editor } from '@tiptap/react' import { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' function useTextCounter(editor: Editor | null, setTextLength: (length: number) => void) { + + const { t } = useTranslation() + const handleTextChange = useCallback( - (currentEditor: Editor) => { + (currentEditor: Editor | null) => { if (currentEditor) { const currentText = currentEditor.getText() setTextLength(currentText.length) if (currentText.length > MAX_CHARACTERS) { currentEditor.chain().undo().run() toaster.create({ - title: 'Characters limitation', - description: 'Exceed the maximum characters', + title: t('components.editorTextCounter.title'), + description: t('components.editorTextCounter.description'), type: 'info', }) } @@ -24,7 +28,7 @@ function useTextCounter(editor: Editor | null, setTextLength: (length: number) = useEffect(() => { if (editor) { - const handleUpdate = ({ editor: currentEditor }: { editor: Editor }) => { + const handleUpdate = ({ editor: currentEditor }: { editor: Editor | null }) => { handleTextChange(currentEditor) } editor.on('update', handleUpdate) From bd17b3b0f1a8f65bb50c45f91403a253fa82e641 Mon Sep 17 00:00:00 2001 From: "ryan.li" Date: Mon, 12 May 2025 23:04:34 +0800 Subject: [PATCH 3/8] Change the condition of exceeding characters limitation --- frontend/src/components/commonUI/TextCounter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/commonUI/TextCounter.tsx b/frontend/src/components/commonUI/TextCounter.tsx index d11b101..911e43a 100644 --- a/frontend/src/components/commonUI/TextCounter.tsx +++ b/frontend/src/components/commonUI/TextCounter.tsx @@ -6,8 +6,8 @@ interface TextCounterProps { } const getTextColor = (currentLength: number) => { - const percentage = currentLength / MAX_CHARACTERS - if (percentage > 0.9) { + const limitation = MAX_CHARACTERS - 20 + if (currentLength > limitation) { return 'red.500' } return 'gray.500' From 7e3cba168e2213dd707091a05b8dc24b69fb939f Mon Sep 17 00:00:00 2001 From: "ryan.li" Date: Mon, 12 May 2025 23:49:39 +0800 Subject: [PATCH 4/8] Fix lint issue --- frontend/src/hooks/useTextCounter.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/hooks/useTextCounter.tsx b/frontend/src/hooks/useTextCounter.tsx index 2b533bc..a5bd32b 100644 --- a/frontend/src/hooks/useTextCounter.tsx +++ b/frontend/src/hooks/useTextCounter.tsx @@ -5,7 +5,6 @@ import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' function useTextCounter(editor: Editor | null, setTextLength: (length: number) => void) { - const { t } = useTranslation() const handleTextChange = useCallback( @@ -23,7 +22,7 @@ function useTextCounter(editor: Editor | null, setTextLength: (length: number) = } } }, - [setTextLength], + [setTextLength, t], ) useEffect(() => { From 0c722fa0805d5858ce8d74f06000499837490b22 Mon Sep 17 00:00:00 2001 From: "ryan.li" Date: Fri, 23 May 2025 22:47:41 +0800 Subject: [PATCH 5/8] Attach while editor used --- frontend/src/components/cards/CardEditor.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/cards/CardEditor.tsx b/frontend/src/components/cards/CardEditor.tsx index dd371ad..165685d 100644 --- a/frontend/src/components/cards/CardEditor.tsx +++ b/frontend/src/components/cards/CardEditor.tsx @@ -27,8 +27,11 @@ export default function CardEditor({ side, isFlipped, frontEditor, backEditor }: const [frontTextLength, setFrontTextLength] = useState(0) const [backTextLength, setBackTextLength] = useState(0) - useTextCounter(frontEditor, setFrontTextLength) - useTextCounter(backEditor, setBackTextLength) + if (side === 'front') { + useTextCounter(frontEditor, setFrontTextLength) + } else if (side === 'back') { + useTextCounter(backEditor, setBackTextLength) + } return ( Date: Sat, 24 May 2025 21:06:11 +0800 Subject: [PATCH 6/8] Update paste text logic --- frontend/src/hooks/useTextCounter.tsx | 34 ++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/frontend/src/hooks/useTextCounter.tsx b/frontend/src/hooks/useTextCounter.tsx index a5bd32b..720c197 100644 --- a/frontend/src/hooks/useTextCounter.tsx +++ b/frontend/src/hooks/useTextCounter.tsx @@ -11,15 +11,28 @@ function useTextCounter(editor: Editor | null, setTextLength: (length: number) = (currentEditor: Editor | null) => { if (currentEditor) { const currentText = currentEditor.getText() - setTextLength(currentText.length) + if (currentText.length > MAX_CHARACTERS) { - currentEditor.chain().undo().run() - toaster.create({ - title: t('components.editorTextCounter.title'), - description: t('components.editorTextCounter.description'), - type: 'info', - }) + const truncatedText = currentText.substring(0, MAX_CHARACTERS) + + if (currentEditor.getText() !== truncatedText) { + setTimeout(() => { + // Prevent race condition with tiptap paste event + if (currentEditor && !currentEditor.isDestroyed) { + currentEditor.commands.setContent(truncatedText) + setTextLength(currentEditor.getText().length) + } + }, 0) + + toaster.create({ + title: t('components.editorTextCounter.title'), + description: t('components.editorTextCounter.description'), + type: 'info', + }) + } } + // Update length no matter truncated or not + setTextLength(currentEditor.getText().length) } }, [setTextLength, t], @@ -28,7 +41,12 @@ function useTextCounter(editor: Editor | null, setTextLength: (length: number) = useEffect(() => { if (editor) { const handleUpdate = ({ editor: currentEditor }: { editor: Editor | null }) => { - handleTextChange(currentEditor) + // Make sure editor has already updated + setTimeout(() => { + if (currentEditor && !currentEditor.isDestroyed) { + handleTextChange(currentEditor); + } + }, 0); } editor.on('update', handleUpdate) // Calculate when initialization From f216e1c36a5df4f2c3b4cbbbebb7c83b44c75e9a Mon Sep 17 00:00:00 2001 From: "ryan.li" Date: Sat, 24 May 2025 21:07:34 +0800 Subject: [PATCH 7/8] Fix lint --- frontend/src/hooks/useTextCounter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useTextCounter.tsx b/frontend/src/hooks/useTextCounter.tsx index 720c197..48ffc19 100644 --- a/frontend/src/hooks/useTextCounter.tsx +++ b/frontend/src/hooks/useTextCounter.tsx @@ -44,9 +44,9 @@ function useTextCounter(editor: Editor | null, setTextLength: (length: number) = // Make sure editor has already updated setTimeout(() => { if (currentEditor && !currentEditor.isDestroyed) { - handleTextChange(currentEditor); + handleTextChange(currentEditor) } - }, 0); + }, 0) } editor.on('update', handleUpdate) // Calculate when initialization From 7bbdc354a9bf057d3be01e4b2442caf3e64326f7 Mon Sep 17 00:00:00 2001 From: 0010aor <4ndres.or@gmail.com> Date: Mon, 26 May 2025 13:57:21 +0200 Subject: [PATCH 8/8] fix: Refactor CardEditor and useTextCounter --- frontend/src/components/cards/CardEditor.tsx | 51 +++++++---- .../src/components/commonUI/TextCounter.tsx | 6 +- frontend/src/hooks/useTextCounter.tsx | 89 +++++++++++-------- frontend/src/utils/text.ts | 1 + 4 files changed, 89 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/cards/CardEditor.tsx b/frontend/src/components/cards/CardEditor.tsx index 165685d..9121f70 100644 --- a/frontend/src/components/cards/CardEditor.tsx +++ b/frontend/src/components/cards/CardEditor.tsx @@ -2,7 +2,7 @@ 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 { useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import TextCounter from '../commonUI/TextCounter' export interface CardEditorProps { @@ -13,25 +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) - if (side === 'front') { - useTextCounter(frontEditor, setFrontTextLength) - } else if (side === 'back') { - useTextCounter(backEditor, setBackTextLength) - } + 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 === 'front' && } {side === 'back' && backEditor && } - {side === 'back' && } + {side === 'back' && } ) diff --git a/frontend/src/components/commonUI/TextCounter.tsx b/frontend/src/components/commonUI/TextCounter.tsx index 911e43a..047492d 100644 --- a/frontend/src/components/commonUI/TextCounter.tsx +++ b/frontend/src/components/commonUI/TextCounter.tsx @@ -1,4 +1,4 @@ -import { MAX_CHARACTERS } from '@/utils/text' +import { MAX_CHARACTERS, WARNING_THRESHOLD } from '@/utils/text' import { Text } from '@chakra-ui/react' interface TextCounterProps { @@ -6,8 +6,8 @@ interface TextCounterProps { } const getTextColor = (currentLength: number) => { - const limitation = MAX_CHARACTERS - 20 - if (currentLength > limitation) { + const warningLimit = MAX_CHARACTERS - WARNING_THRESHOLD + if (currentLength > warningLimit) { return 'red.500' } return 'gray.500' diff --git a/frontend/src/hooks/useTextCounter.tsx b/frontend/src/hooks/useTextCounter.tsx index 48ffc19..3135cdc 100644 --- a/frontend/src/hooks/useTextCounter.tsx +++ b/frontend/src/hooks/useTextCounter.tsx @@ -1,64 +1,79 @@ import { toaster } from '@/components/ui/toaster' import { MAX_CHARACTERS } from '@/utils/text' import type { Editor } from '@tiptap/react' -import { useCallback, useEffect } from '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 | null) => { - if (currentEditor) { - const currentText = currentEditor.getText() + (currentEditor: Editor) => { + const currentText = currentEditor.getText() + const textLength = currentText.length - if (currentText.length > MAX_CHARACTERS) { - const truncatedText = currentText.substring(0, MAX_CHARACTERS) + 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', + }) - if (currentEditor.getText() !== truncatedText) { - setTimeout(() => { - // Prevent race condition with tiptap paste event - if (currentEditor && !currentEditor.isDestroyed) { - currentEditor.commands.setContent(truncatedText) - setTextLength(currentEditor.getText().length) - } - }, 0) - - toaster.create({ - title: t('components.editorTextCounter.title'), - description: t('components.editorTextCounter.description'), - type: 'info', - }) - } + timeoutRef.current = setTimeout(() => { + hasShownToastRef.current = false + }, 3000) } - // Update length no matter truncated or not - setTextLength(currentEditor.getText().length) + + setTextLength(MAX_CHARACTERS) + } else { + hasShownToastRef.current = false + setTextLength(textLength) } }, [setTextLength, t], ) useEffect(() => { - if (editor) { - const handleUpdate = ({ editor: currentEditor }: { editor: Editor | null }) => { - // Make sure editor has already updated - setTimeout(() => { - if (currentEditor && !currentEditor.isDestroyed) { - handleTextChange(currentEditor) - } - }, 0) + 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 } - editor.on('update', handleUpdate) - // Calculate when initialization - handleTextChange(editor) - return () => { + if (editor && !editor.isDestroyed) { editor.off('update', handleUpdate) } } - }, [editor, handleTextChange]) + }, [editor, handleTextChange, setTextLength]) - return + 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 90a111e..b26df8f 100644 --- a/frontend/src/utils/text.ts +++ b/frontend/src/utils/text.ts @@ -4,3 +4,4 @@ export function stripHtml(html: string) { } export const MAX_CHARACTERS = 3000 +export const WARNING_THRESHOLD = 20