Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 87 additions & 15 deletions apps/web/components/grok-tts-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ type InstantTagDef = (typeof INSTANT_TAGS)[number];
type WrapperTagDef = (typeof WRAPPING_TAGS)[number];
type TagDef = InstantTagDef | WrapperTagDef;

/**
* Extra characters tolerated above `charactersLimit` before the editor clamps
* its own content. The counter turns red as soon as `charactersLimit` is
* exceeded, so this grace window lets a slightly-too-long paste land in the
* document — and stay visible and editable — instead of being cut at the exact
* limit while the user is still typing.
*/
export const GROK_CHARACTERS_LIMIT_GRACE = 10;

const KNOWN_INSTANT_TAGS = new Set(GROK_INSTANT_TAGS);

function isKnownInstantTag(tag: string): tag is GrokInstantTag {
Expand Down Expand Up @@ -207,6 +216,44 @@ function moveEditorSelectionToEnd(
return selection;
}

function clampTextToCharactersLimit(
text: string,
charactersLimit: number,
enforceCharactersLimit: boolean,
): string {
return enforceCharactersLimit
? text.slice(0, charactersLimit + GROK_CHARACTERS_LIMIT_GRACE)
: text;
}

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<SuggestionMenuProps['allow']>;
customItems: SuggestionItem[];
Expand Down Expand Up @@ -297,6 +344,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<EditorSelectionSnapshot | null>(null);
const lastSelectionRef = useRef<EditorSelectionSnapshot>({
empty: true,
Expand All @@ -307,7 +355,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),
Expand Down Expand Up @@ -373,15 +422,18 @@ export function GrokTTSEditor({
},
onUpdate: ({ editor: nextEditor }) => {
const fullText = grokTipTapDocToText(nextEditor.getJSON());
const text = enforceCharactersLimitRef.current
? fullText.slice(0, charactersLimitRef.current + 10)
: fullText;

if (text !== fullText) {
nextEditor.commands.setContent(plainTextToDoc(text));
const resetSelection = moveEditorSelectionToEnd(nextEditor);
lastSelectionRef.current = resetSelection;
contentResetSelectionRef.current = resetSelection;
const clampedText = clampTextToCharactersLimit(
fullText,
charactersLimitRef.current,
enforceCharactersLimitRef.current,
);
let text = fullText;

if (clampedText !== fullText) {
const applied = applyEditorContent(nextEditor, clampedText);
lastSelectionRef.current = applied.selection;
contentResetSelectionRef.current = applied.selection;
text = applied.text;
}

setCurrentLength(text.length);
Expand All @@ -400,11 +452,31 @@ export function GrokTTSEditor({
return;
}

editor.commands.setContent(plainTextToDoc(value));
const resetSelection = moveEditorSelectionToEnd(editor);
lastSelectionRef.current = resetSelection;
contentResetSelectionRef.current = resetSelection;
setCurrentLength(value.length);
const applied = applyEditorContent(
editor,
clampTextToCharactersLimit(
value,
charactersLimitRef.current,
enforceCharactersLimitRef.current,
),
);
Comment thread
gianpaj marked this conversation as resolved.
Outdated
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) => {
Expand Down
164 changes: 132 additions & 32 deletions apps/web/tests/components/grok-tts-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import {
act,
fireEvent,
render,
screen,
Expand All @@ -11,7 +12,11 @@ import userEvent from '@testing-library/user-event';
import { NextIntlClientProvider } from 'next-intl';
import { describe, expect, it, vi } from 'vitest';

import { GrokTTSEditor } from '@/components/grok-tts-editor';
import {
GROK_CHARACTERS_LIMIT_GRACE,
GrokTTSEditor,
} from '@/components/grok-tts-editor';
import { GROK_EMPTY_WRAPPING_TEXT } from '@/lib/tts-editor';
import messages from '@/messages/en.json';

const UNSUPPORTED_GROK_TAG_HIGHLIGHT_CLASSES = [
Expand Down Expand Up @@ -42,38 +47,72 @@ function getSuggestionDecoration(editor: HTMLElement) {
return editor.querySelector('[data-decoration-content="Filter..."]');
}

function renderEditor({
charactersLimit = 500,
enforceCharactersLimit = true,
onChange = vi.fn(),
placeholder = messages.generate.textAreaPlaceholder,
selectedGrokLanguage = 'auto',
setSelectedGrokLanguage = vi.fn(),
value = '',
}: {
interface EditorProps {
charactersLimit?: number;
enforceCharactersLimit?: boolean;
onChange?: (text: string) => void;
placeholder?: string;
selectedGrokLanguage?: string;
setSelectedGrokLanguage?: (text: string) => void;
value?: string;
} = {}) {
return render(
}

type ResolvedEditorProps = Required<EditorProps>;

function editorTree(props: ResolvedEditorProps) {
return (
<NextIntlClientProvider locale="en" messages={messages}>
<GrokTTSEditor
charactersLimit={charactersLimit}
enforceCharactersLimit={enforceCharactersLimit}
onChange={onChange}
placeholder={placeholder}
selectedGrokLanguage={selectedGrokLanguage}
setSelectedGrokLanguage={setSelectedGrokLanguage}
value={value}
charactersLimit={props.charactersLimit}
enforceCharactersLimit={props.enforceCharactersLimit}
onChange={props.onChange}
placeholder={props.placeholder}
selectedGrokLanguage={props.selectedGrokLanguage}
setSelectedGrokLanguage={props.setSelectedGrokLanguage}
value={props.value}
/>
</NextIntlClientProvider>,
</NextIntlClientProvider>
);
}

function renderEditor({
charactersLimit = 500,
enforceCharactersLimit = true,
onChange = vi.fn(),
placeholder = messages.generate.textAreaPlaceholder,
selectedGrokLanguage = 'auto',
setSelectedGrokLanguage = vi.fn(),
value = '',
}: EditorProps = {}) {
const props: ResolvedEditorProps = {
charactersLimit,
enforceCharactersLimit,
onChange,
placeholder,
selectedGrokLanguage,
setSelectedGrokLanguage,
value,
};
const rendered = render(editorTree(props));

return {
...rendered,
// Re-renders against the same resolved props as the initial render, so a
// test can change one prop without silently dropping the others.
rerenderWith: (nextProps: EditorProps) =>
rendered.rerender(editorTree({ ...props, ...nextProps })),
};
}

// `waitFor` resolves on the first matching call, so it cannot prove a value was
// emitted only once. Settling pending effects first makes the follow-up
// `toHaveBeenCalledTimes` assertion catch a duplicate arriving a tick later.
async function settleEffects() {
await act(async () => {
await Promise.resolve();
});
}

function selectEditorText(editor: HTMLElement, text: string) {
const paragraph = editor.querySelector('p');

Expand Down Expand Up @@ -191,6 +230,78 @@ describe('GrokTTSEditor', () => {
expect(screen.getByText('17 / 500')).toBeInTheDocument();
});

it('synchronizes an external value without emitting onChange', async () => {
const onChange = vi.fn();
const rendered = renderEditor({ onChange, value: 'Initial value' });
const editor = await findEditor();

onChange.mockClear();
rendered.rerenderWith({ value: 'External value' });

await waitFor(() => {
expect(editor).toHaveTextContent('External value');
expect(screen.getByText('14 / 500')).toBeInTheDocument();
});
expect(onChange).not.toHaveBeenCalled();
});

it('reports the normalized text when an external value does not survive the editor round trip', async () => {
const onChange = vi.fn();
// Serialization strips GROK_EMPTY_WRAPPING_TEXT wherever it appears, so
// this 11-character value renders as a 10-character document.
const externalValue = `Hello${GROK_EMPTY_WRAPPING_TEXT}world`;
const rendered = renderEditor({ onChange, value: 'Initial value' });

await findEditor();
onChange.mockClear();
rendered.rerenderWith({ value: externalValue });

await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('Helloworld');
});
await settleEffects();
expect(onChange).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('generate-character-count')).toHaveTextContent(
'10 / 500',
);
});

it('clamps an external value that exceeds the character limit', async () => {
const onChange = vi.fn();
const clampedLength = 5 + GROK_CHARACTERS_LIMIT_GRACE;
const rendered = renderEditor({ charactersLimit: 5, onChange, value: '' });
const editor = await findEditor();

onChange.mockClear();
rendered.rerenderWith({ value: 'A'.repeat(40) });

await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('A'.repeat(clampedLength));
});
await settleEffects();
expect(onChange).toHaveBeenCalledTimes(1);
expect(editor).toHaveTextContent('A'.repeat(clampedLength));
expect(screen.getByText(`${clampedLength} / 5`)).toBeInTheDocument();
});

it('emits one clamped value when pasted text exceeds the character limit', async () => {
const onChange = vi.fn();
const pastedText = 'A'.repeat(20);
const clampedText = 'A'.repeat(5 + GROK_CHARACTERS_LIMIT_GRACE);

renderEditor({ charactersLimit: 5, onChange });

const editor = await findEditor();
onChange.mockClear();
pasteIntoEditor(editor, pastedText);

await waitFor(() => {
expect(editor).toHaveTextContent(clampedText);
expect(onChange).toHaveBeenCalledWith(clampedText);
});
expect(onChange).toHaveBeenCalledTimes(1);
});

it('opens the effects popover and shows available effect actions', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
Expand Down Expand Up @@ -322,18 +433,7 @@ describe('GrokTTSEditor', () => {
const editor = await findEditor();
selectEditorText(editor, 'world');

rendered.rerender(
<NextIntlClientProvider locale="en" messages={messages}>
<GrokTTSEditor
charactersLimit={500}
onChange={onChange}
placeholder={messages.generate.textAreaPlaceholder}
selectedGrokLanguage="auto"
setSelectedGrokLanguage={vi.fn()}
value="Hi"
/>
</NextIntlClientProvider>,
);
rendered.rerenderWith({ value: 'Hi' });

await waitFor(() => {
expect(editor).toHaveTextContent('Hi');
Expand Down
Loading