diff --git a/apps/web/app/[lang]/(dashboard)/dashboard/clone/clone-text-field.tsx b/apps/web/app/[lang]/(dashboard)/dashboard/clone/clone-text-field.tsx index 007faa7e4..5a91cffb1 100644 --- a/apps/web/app/[lang]/(dashboard)/dashboard/clone/clone-text-field.tsx +++ b/apps/web/app/[lang]/(dashboard)/dashboard/clone/clone-text-field.tsx @@ -14,6 +14,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { CLONE_TEXT_MAX_LENGTH_VOXTRAL_PAID } from '@/lib/clone/constants'; +import { CHARACTERS_LIMIT_GRACE } from '@/lib/ui-constants'; import { cn } from '@/lib/utils'; import { type CloneStateAction, formatCloneMessage } from './clone-state'; @@ -49,7 +50,7 @@ export function CloneTextField({ data-testid="clone-text-input" disabled={disabled} id="text-to-convert" - maxLength={textMaxLength + 30} + maxLength={textMaxLength + CHARACTERS_LIMIT_GRACE} onChange={(e) => { dispatch({ patch: { text: e.target.value }, diff --git a/apps/web/components/grok-tts-editor.tsx b/apps/web/components/grok-tts-editor.tsx index 55d2eab68..eba73bc90 100644 --- a/apps/web/components/grok-tts-editor.tsx +++ b/apps/web/components/grok-tts-editor.tsx @@ -49,6 +49,7 @@ import { grokTextToTipTapDoc, grokTipTapDocToText, } from '@/lib/tts-editor'; +import { CHARACTERS_LIMIT_GRACE } from '@/lib/ui-constants'; import { cn } from '@/lib/utils'; import { UiState } from './tiptap/tiptap-extension/ui-state-extension'; import { SlashDropdownMenu } from './tiptap/tiptap-ui/slash-dropdown-menu/slash-dropdown-menu'; @@ -207,6 +208,34 @@ function moveEditorSelectionToEnd( return selection; } +interface AppliedEditorContent { + selection: EditorSelectionSnapshot; + text: string; +} + +/** + * Replaces the document without re-entering `onUpdate`, and reports what the + * editor actually ended up with. + * + * `plainTextToDoc(text)` is only a request: ProseMirror coerces it to the + * schema and `AutoConvertGrokTags` can rewrite it again in an appended + * transaction, so the round trip is not guaranteed to be the identity. Because + * `emitUpdate: false` suppresses the update that used to report the result, + * callers must reconcile against the returned `text` rather than assume the + * text they passed in was applied verbatim. + */ +function applyEditorContent( + editor: EditorInstance, + text: string, +): AppliedEditorContent { + editor.commands.setContent(plainTextToDoc(text), { emitUpdate: false }); + + return { + selection: moveEditorSelectionToEnd(editor), + text: grokTipTapDocToText(editor.getJSON()), + }; +} + interface GrokSlashMenuConfig { allow?: NonNullable; customItems: SuggestionItem[]; @@ -297,6 +326,7 @@ export function GrokTTSEditor({ const [currentLength, setCurrentLength] = useState(value.length); const charactersLimitRef = useRef(charactersLimit); const enforceCharactersLimitRef = useRef(enforceCharactersLimit); + const onChangeRef = useRef(onChange); const contentResetSelectionRef = useRef(null); const lastSelectionRef = useRef({ empty: true, @@ -307,7 +337,8 @@ export function GrokTTSEditor({ useEffect(() => { charactersLimitRef.current = charactersLimit; enforceCharactersLimitRef.current = enforceCharactersLimit; - }, [charactersLimit, enforceCharactersLimit]); + onChangeRef.current = onChange; + }, [charactersLimit, enforceCharactersLimit, onChange]); const editor = useEditor({ content: plainTextToDoc(value), @@ -373,15 +404,19 @@ export function GrokTTSEditor({ }, onUpdate: ({ editor: nextEditor }) => { const fullText = grokTipTapDocToText(nextEditor.getJSON()); - const text = enforceCharactersLimitRef.current - ? fullText.slice(0, charactersLimitRef.current + 10) + // Clamps user input only, mirroring `maxLength` on the other limited + // textareas. Text arriving from the parent is applied as given — see the + // content synchronization effect below. + const clampedText = enforceCharactersLimitRef.current + ? fullText.slice(0, charactersLimitRef.current + CHARACTERS_LIMIT_GRACE) : fullText; + let text = fullText; - if (text !== fullText) { - nextEditor.commands.setContent(plainTextToDoc(text)); - const resetSelection = moveEditorSelectionToEnd(nextEditor); - lastSelectionRef.current = resetSelection; - contentResetSelectionRef.current = resetSelection; + if (clampedText !== fullText) { + const applied = applyEditorContent(nextEditor, clampedText); + lastSelectionRef.current = applied.selection; + contentResetSelectionRef.current = applied.selection; + text = applied.text; } setCurrentLength(text.length); @@ -400,11 +435,32 @@ export function GrokTTSEditor({ return; } - editor.commands.setContent(plainTextToDoc(value)); - const resetSelection = moveEditorSelectionToEnd(editor); - lastSelectionRef.current = resetSelection; - contentResetSelectionRef.current = resetSelection; - setCurrentLength(value.length); + // Applied as given, deliberately not clamped. `useEditor`'s initial + // `content` above cannot clamp either, and `audio-generator.tsx` shares one + // `text` state between this editor and the non-Grok one, so switching from + // a higher-limit voice mounts this editor with over-limit text. Clamping + // here would truncate that text on the sync path but not on mount, and + // would silently destroy input the user can still see. Over-limit text is + // surfaced by the red counter and blocks generation through + // `textIsOverLimit` in the parent. + const applied = applyEditorContent(editor, value); + lastSelectionRef.current = applied.selection; + contentResetSelectionRef.current = applied.selection; + setCurrentLength(applied.text.length); + + // `applyEditorContent` suppresses the update that used to push the applied + // text back out, so reconcile here instead: when the editor clamps or + // normalizes what it was handed, the parent would otherwise keep a value + // that no longer matches the visible document. `applied.text` was read back + // from the editor, so the next pass hits the early return above and this + // settles after one extra render. + // + // Read through the ref rather than depending on `onChange`, so an inline + // parent callback cannot make this effect re-run — and reset the document + // and caret — on renders where `value` did not change. + if (applied.text !== value) { + onChangeRef.current(applied.text); + } }, [editor, value]); const insertInstantTag = (tag: InstantTagDef) => { diff --git a/apps/web/components/non-grok-editor.tsx b/apps/web/components/non-grok-editor.tsx index ce059a2d5..8e714810f 100644 --- a/apps/web/components/non-grok-editor.tsx +++ b/apps/web/components/non-grok-editor.tsx @@ -4,6 +4,7 @@ import { Crown, Loader2, Maximize2, Minimize2, Sparkles } from 'lucide-react'; import { useTranslations } from 'next-intl'; import type { CSSProperties, ReactNode, RefObject } from 'react'; +import { CHARACTERS_LIMIT_GRACE } from '@/lib/ui-constants'; import { cn } from '@/lib/utils'; import { AnimatedPromptTextarea } from './audio-generator'; import { Button } from './ui/button'; @@ -62,7 +63,7 @@ export function NonGrokPromptEditor({ maxLength={ textareaMaxLength === null ? undefined - : (textareaMaxLength ?? charactersLimit + 10) + : (textareaMaxLength ?? charactersLimit + CHARACTERS_LIMIT_GRACE) } onChange={(e) => onTextChange(e.target.value)} placeholder={t('textAreaPlaceholder')} diff --git a/apps/web/components/voice-selector.tsx b/apps/web/components/voice-selector.tsx index c6f065a14..72f6a0a95 100644 --- a/apps/web/components/voice-selector.tsx +++ b/apps/web/components/voice-selector.tsx @@ -20,6 +20,7 @@ import { import { VoiceSelect } from '@/components/voice-select'; import { getEmotionTags, getGeminiStyleCharacterLimit } from '@/lib/ai'; import { resizeTextarea } from '@/lib/react-textarea-autosize'; +import { CHARACTERS_LIMIT_GRACE } from '@/lib/ui-constants'; import { capitalizeFirstLetter, cn, getTtsProvider } from '@/lib/utils'; import { AudioPlayerWithContext } from './audio-player-with-context'; import { GrokTaggedText } from './grok-tagged-text'; @@ -163,7 +164,7 @@ export function VoiceSelector({