Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
39 changes: 37 additions & 2 deletions __tests__/components/RichTextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe('RichTextInput', () => {
const actions = onReady.mock.calls[0][0];
expect(actions.toggleFormat).toBeDefined();
expect(actions.handleTextChange).toBeDefined();
expect(actions.getOutput).toBeDefined();
expect(actions.exportJSON).toBeDefined();
expect(actions.clear).toBeDefined();
});
Expand All @@ -138,7 +139,7 @@ describe('RichTextInput', () => {
expect(input.props.maxLength).toBe(100);
});

it('keeps the editable text hidden while merging caller styles', () => {
it('keeps caller text input styles on the visible input', () => {
const { getByPlaceholderText } = render(
<RichTextInput
placeholder="Type..."
Expand All @@ -150,7 +151,7 @@ describe('RichTextInput', () => {
expect(input.props.style).toEqual(
expect.arrayContaining([
expect.objectContaining({ letterSpacing: 2 }),
expect.objectContaining({ color: 'transparent' }),
expect.objectContaining({ color: expect.any(String) }),
]),
);
});
Expand All @@ -166,6 +167,40 @@ describe('RichTextInput', () => {
expect(getByPlaceholderText('Type...').props.scrollEnabled).toBe(true);
});

it('shows markdown output below the input as content changes', () => {
const { getByPlaceholderText, getByText } = render(
<RichTextInput placeholder="Type..." />,
);

fireEvent.changeText(getByPlaceholderText('Type...'), 'Hello');

expect(getByText('Markdown output')).toBeTruthy();
expect(getByText('Hello')).toBeTruthy();
});

it('renders HTML output when requested', () => {
const { getByText } = render(
<RichTextInput
outputFormat="html"
initialSegments={[createSegment('Title', { heading: 'h1' })]}
/>,
);

expect(getByText('HTML output')).toBeTruthy();
expect(getByText('<h1>Title</h1>')).toBeTruthy();
});

it('hides the output panel when showOutputPreview is false', () => {
const { queryByText } = render(
<RichTextInput
showOutputPreview={false}
initialSegments={[createSegment('Hello')]}
/>,
);

expect(queryByText('Markdown output')).toBeNull();
});

it('renders with minHeight', () => {
const { toJSON } = render(
<RichTextInput minHeight={200} />,
Expand Down
1 change: 1 addition & 0 deletions __tests__/components/Toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const mockActions: RichTextActions = {
handleSelectionChange: jest.fn(),
isFormatActive: jest.fn(() => false),
getSelectionStyle: jest.fn(() => ({ ...EMPTY_FORMAT_STYLE })),
getOutput: jest.fn(() => ''),
getPlainText: jest.fn(() => ''),
exportJSON: jest.fn(() => []),
importJSON: jest.fn(),
Expand Down
12 changes: 12 additions & 0 deletions __tests__/hooks/useFormatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ describe('useFormatting', () => {

expect(onSegmentsChange).toHaveBeenCalled();
});

it('updates active heading when no text is selected', () => {
const { hook, onActiveStylesChange } = createFormattingHook();

act(() => {
hook.result.current.setHeading('h2');
});

expect(onActiveStylesChange).toHaveBeenCalledWith(
expect.objectContaining({ heading: 'h2' }),
);
});
});

describe('isFormatActive', () => {
Expand Down
56 changes: 56 additions & 0 deletions __tests__/hooks/useRichText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,36 @@ describe('useRichText', () => {
expect(onChangeSegments).toHaveBeenCalled();
});

it('preserves active styles while typing in the middle of text', () => {
const { result } = renderHook(() =>
useRichText({
initialSegments: [createSegment('HelloWorld')],
}),
);

act(() => {
result.current.actions.handleSelectionChange({ start: 5, end: 5 });
});

act(() => {
result.current.actions.toggleFormat('bold');
});

act(() => {
result.current.actions.handleSelectionChange({ start: 6, end: 6 });
result.current.actions.handleTextChange('HelloaWorld');
});

act(() => {
result.current.actions.handleSelectionChange({ start: 7, end: 7 });
result.current.actions.handleTextChange('HelloabWorld');
});

expect(result.current.actions.getOutput('markdown')).toBe(
'Hello**ab**World',
);
});

it('inherits selection styles when replacing selected text', () => {
const { result } = renderHook(() =>
useRichText({
Expand All @@ -85,6 +115,17 @@ describe('useRichText', () => {
expect(result.current.state.segments[0].text).toBe('X');
expect(result.current.state.segments[0].styles.bold).toBe(true);
});

it('serializes output as markdown or HTML', () => {
const { result } = renderHook(() =>
useRichText({
initialSegments: [createSegment('Title', { heading: 'h1' })],
}),
);

expect(result.current.actions.getOutput('markdown')).toBe('# Title');
expect(result.current.actions.getOutput('html')).toBe('<h1>Title</h1>');
});
});

// ─── Selection Changes ──────────────────────────────────────────────────
Expand Down Expand Up @@ -235,6 +276,21 @@ describe('useRichText', () => {

expect(result.current.state.segments[0].styles.heading).toBe('h1');
});

it('stores heading as an active style for future typing', () => {
const { result } = renderHook(() => useRichText());

act(() => {
result.current.actions.setHeading('h3');
});

act(() => {
result.current.actions.handleSelectionChange({ start: 1, end: 1 });
result.current.actions.handleTextChange('A');
});

expect(result.current.actions.getOutput('markdown')).toBe('### A');
});
});

// ─── Export / Import ─────────────────────────────────────────────────────
Expand Down
9 changes: 2 additions & 7 deletions __tests__/utils/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,6 @@ describe('formatter', () => {
);

expect(result[0].styles.heading).toBe('h1');
expect(result[0].styles.fontSize).toBe(32);
expect(result[0].styles.bold).toBe(true);
});

it('applies h2 heading', () => {
Expand All @@ -187,7 +185,6 @@ describe('formatter', () => {
);

expect(result[0].styles.heading).toBe('h2');
expect(result[0].styles.fontSize).toBe(24);
});

it('applies h3 heading', () => {
Expand All @@ -199,19 +196,17 @@ describe('formatter', () => {
);

expect(result[0].styles.heading).toBe('h3');
expect(result[0].styles.fontSize).toBe(20);
});

it('removes heading when set to none', () => {
const segments = [createSegment('Title', { heading: 'h1', fontSize: 32, bold: true })];
const segments = [createSegment('Title', { heading: 'h1' })];
const result = setHeadingOnLine(
segments,
{ start: 0, end: 0 },
'none',
);

expect(result[0].styles.heading).toBe('none');
expect(result[0].styles.fontSize).toBe(16);
expect(result[0].styles.heading).toBeUndefined();
});

it('only affects the line at cursor in multi-line text', () => {
Expand Down
66 changes: 66 additions & 0 deletions __tests__/utils/serializer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
serializeSegments,
segmentsToHTML,
segmentsToMarkdown,
} from '../../src/utils/serializer';
import { createSegment } from '../../src/utils/parser';

describe('serializer', () => {
it('serializes markdown inline formats', () => {
const output = segmentsToMarkdown([
createSegment('Hello', { bold: true }),
createSegment(' world', { italic: true }),
]);

expect(output).toBe('**Hello*** world*');
});

it('serializes headings as markdown blocks', () => {
const output = serializeSegments(
[createSegment('Title', { heading: 'h1' })],
'markdown',
);

expect(output).toBe('# Title');
});

it('serializes headings and inline styles as HTML', () => {
const output = segmentsToHTML([
createSegment('Title', { heading: 'h2' }),
createSegment('!', { heading: 'h2', color: '#ff0000' }),
]);

expect(output).toBe(
'<h2>Title<span style="color: #ff0000">!</span></h2>',
);
});

it('uses inline HTML for markdown-only unsupported styles', () => {
const output = serializeSegments(
[
createSegment('Marked', {
underline: true,
color: '#00aa00',
}),
],
'markdown',
);

expect(output).toBe(
'<span style="color: #00aa00"><u>Marked</u></span>',
);
});

it('preserves line breaks between serialized blocks', () => {
const output = serializeSegments(
[
createSegment('Title', { heading: 'h3' }),
createSegment('\n'),
createSegment('Paragraph'),
],
'html',
);

expect(output).toBe('<h3>Title</h3>\n<p>Paragraph</p>');
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-native-richify",
"version": "1.0.3",
"version": "1.0.4",
"description": "A production-grade, fully customizable React Native Rich Text Input using the Overlay Technique — no WebView required.",
"main": "lib/commonjs/index.js",
"module": "lib/module/index.js",
Expand Down
17 changes: 3 additions & 14 deletions src/components/RichTextInput.d.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
import React from 'react';
import type { RichTextInputProps } from '../types';
/**
* RichTextInput The main rich text editor component.
* RichTextInput — The main rich text editor component.
*
* Uses the Overlay Technique:
* - A transparent `TextInput` on top captures user input and selection
* - A styled `<Text>` layer behind it renders the formatted content
* - Both share identical font metrics for pixel-perfect alignment
*
* @example
* ```tsx
* <RichTextInput
* placeholder="Start typing..."
* showToolbar
* onChangeSegments={(segments) => console.log(segments)}
* />
* ```
* Uses a plain `TextInput` for editing and renders the serialized rich output
* below it as Markdown or HTML.
*/
export declare const RichTextInput: React.FC<RichTextInputProps>;
Loading
Loading