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
6 changes: 3 additions & 3 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
permissions:
contents: read
container:
image: node:20-alpine
image: node:22.14.0-alpine
defaults:
run:
shell: sh
Expand All @@ -40,9 +40,9 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-alpine-npm-${{ hashFiles('package-lock.json') }}
key: ${{ runner.os }}-node22.14.0-alpine-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node20-alpine-npm-
${{ runner.os }}-node22.14.0-alpine-npm-

- name: Install dependencies
run: npm ci --prefer-offline
Expand Down
48 changes: 48 additions & 0 deletions __tests__/components/RichTextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ describe('RichTextInput', () => {
expect(getByPlaceholderText('Type...').props.scrollEnabled).toBe(false);

rerender(<RichTextInput placeholder="Type..." maxHeight={300} />);
const input = getByPlaceholderText('Type...');
fireEvent(input, 'contentSizeChange', {
nativeEvent: { contentSize: { height: 420, width: 200 } },
});

expect(getByPlaceholderText('Type...').props.scrollEnabled).toBe(true);
});

Expand Down Expand Up @@ -201,6 +206,49 @@ describe('RichTextInput', () => {
expect(queryByText('Markdown output')).toBeNull();
});

it('switches between markdown and html output from the toolbar', () => {
const { getByPlaceholderText, getByText } = render(
<RichTextInput placeholder="Type..." />,
);

fireEvent.changeText(getByPlaceholderText('Type...'), 'Hello');
fireEvent.press(getByText('HTML'));

expect(getByText('HTML output')).toBeTruthy();
expect(getByText('<p>Hello</p>')).toBeTruthy();
});

it('switches between raw output and rendered preview from the toolbar', () => {
const { getByText, queryByText } = render(
<RichTextInput
initialSegments={[createSegment('Title', { heading: 'h1' })]}
/>,
);

expect(getByText('# Title')).toBeTruthy();

fireEvent.press(getByText('View'));

expect(getByText('Markdown preview')).toBeTruthy();
expect(queryByText('# Title')).toBeNull();
expect(getByText('Title')).toBeTruthy();
});

it('grows the input height before maxHeight is reached', () => {
const { getByPlaceholderText } = render(
<RichTextInput placeholder="Type..." minHeight={120} />,
);

const input = getByPlaceholderText('Type...');
fireEvent(input, 'contentSizeChange', {
nativeEvent: { contentSize: { height: 220, width: 200 } },
});

expect(input.props.style).toEqual(
expect.arrayContaining([expect.objectContaining({ height: 220 })]),
);
});

it('renders with minHeight', () => {
const { toJSON } = render(
<RichTextInput minHeight={200} />,
Expand Down
25 changes: 24 additions & 1 deletion __tests__/components/Toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const mockActions: RichTextActions = {
toggleFormat: jest.fn(),
setStyleProperty: jest.fn(),
setHeading: jest.fn(),
setListType: jest.fn(),
setTextAlign: jest.fn(),
setLink: jest.fn(),
insertImage: jest.fn(),
setColor: jest.fn(),
setBackgroundColor: jest.fn(),
setFontSize: jest.fn(),
Expand Down Expand Up @@ -166,6 +170,25 @@ describe('Toolbar', () => {

expect(mockActions.isFormatActive).toHaveBeenCalledWith('bold');
expect(mockActions.getSelectionStyle).toHaveBeenCalled();
expect(selectedButtons).toHaveLength(2);
expect(selectedButtons).toHaveLength(4);
});

it('routes output format and preview mode buttons through toolbar callbacks', () => {
const onOutputFormatChange = jest.fn();
const onOutputPreviewModeChange = jest.fn();
const { getByText } = render(
<Toolbar
actions={mockActions}
state={mockState}
onOutputFormatChange={onOutputFormatChange}
onOutputPreviewModeChange={onOutputPreviewModeChange}
/>,
);

fireEvent.press(getByText('HTML'));
fireEvent.press(getByText('View'));

expect(onOutputFormatChange).toHaveBeenCalledWith('html');
expect(onOutputPreviewModeChange).toHaveBeenCalledWith('rendered');
});
});
57 changes: 57 additions & 0 deletions __tests__/hooks/useFormatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,63 @@ describe('useFormatting', () => {
expect.objectContaining({ heading: 'h2' }),
);
});

it('toggles the same active heading level off', () => {
const { hook, onActiveStylesChange } = createFormattingHook({
activeStyles: { ...EMPTY_FORMAT_STYLE, heading: 'h2' },
});

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

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

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

act(() => {
hook.result.current.setListType('ordered');
});

expect(onActiveStylesChange).toHaveBeenCalledWith(
expect.objectContaining({ listType: 'ordered', heading: undefined }),
);
});
});

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

act(() => {
hook.result.current.setTextAlign('right');
});

expect(onActiveStylesChange).toHaveBeenCalledWith(
expect.objectContaining({ textAlign: 'right' }),
);
});
});

describe('setLink', () => {
it('applies link style to the selected text', () => {
const { hook, onSegmentsChange } = createFormattingHook({
selection: { start: 0, end: 5 },
});

act(() => {
hook.result.current.setLink('https://openai.com');
});

const newSegments = onSegmentsChange.mock.calls[0][0];
expect(newSegments[0].styles.link).toBe('https://openai.com');
});
});

describe('isFormatActive', () => {
Expand Down
88 changes: 88 additions & 0 deletions __tests__/hooks/useRichText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,21 @@ describe('useRichText', () => {
expect(result.current.state.segments[0].styles.heading).toBe('h1');
});

it('toggles an applied heading off when the same button is pressed again', () => {
const { result } = renderHook(() =>
useRichText({
initialSegments: [createSegment('Title', { heading: 'h1' })],
}),
);

act(() => {
result.current.actions.handleSelectionChange({ start: 0, end: 0 });
result.current.actions.setHeading('h1');
});

expect(result.current.state.segments[0].styles.heading).toBeUndefined();
});

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

Expand All @@ -293,6 +308,79 @@ describe('useRichText', () => {
});
});

describe('setListType', () => {
it('applies list formatting to the current line', () => {
const { result } = renderHook(() =>
useRichText({
initialSegments: [createSegment('First item')],
}),
);

act(() => {
result.current.actions.setListType('bullet');
});

expect(result.current.actions.getOutput('markdown')).toBe('- First item');
});
});

describe('setTextAlign', () => {
it('serializes alignment through markdown output using html fallback', () => {
const { result } = renderHook(() =>
useRichText({
initialSegments: [createSegment('Centered copy')],
}),
);

act(() => {
result.current.actions.setTextAlign('center');
});

expect(result.current.actions.getOutput('markdown')).toBe(
'<p style="text-align: center">Centered copy</p>',
);
});
});

describe('setLink', () => {
it('applies a link to selected text', () => {
const { result } = renderHook(() =>
useRichText({
initialSegments: [createSegment('OpenAI')],
}),
);

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

act(() => {
result.current.actions.setLink('https://openai.com');
});

expect(result.current.actions.getOutput('markdown')).toBe(
'[OpenAI](https://openai.com)',
);
});
});

describe('insertImage', () => {
it('inserts an image placeholder and serializes it', () => {
const { result } = renderHook(() => useRichText());

act(() => {
result.current.actions.insertImage('https://cdn.test/photo.png', {
alt: 'Photo',
});
});

expect(result.current.actions.getPlainText()).toBe('[Image: Photo]');
expect(result.current.actions.getOutput('markdown')).toBe(
'![Photo](https://cdn.test/photo.png)',
);
});
});

// ─── Export / Import ─────────────────────────────────────────────────────

describe('getPlainText', () => {
Expand Down
28 changes: 28 additions & 0 deletions __tests__/utils/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
toggleFormatOnSelection,
setStyleOnSelection,
setHeadingOnLine,
setListTypeOnLine,
setTextAlignOnLine,
isFormatActiveInSelection,
getSelectionStyle,
} from '../../src/utils/formatter';
Expand Down Expand Up @@ -223,6 +225,32 @@ describe('formatter', () => {
});
});

describe('setListTypeOnLine', () => {
it('applies bullet list formatting to the current line', () => {
const segments = [createSegment('Item')];
const result = setListTypeOnLine(
segments,
{ start: 0, end: 0 },
'bullet',
);

expect(result[0].styles.listType).toBe('bullet');
});
});

describe('setTextAlignOnLine', () => {
it('applies text alignment to the current line', () => {
const segments = [createSegment('Centered')];
const result = setTextAlignOnLine(
segments,
{ start: 0, end: 0 },
'center',
);

expect(result[0].styles.textAlign).toBe('center');
});
});

// ─── isFormatActiveInSelection ───────────────────────────────────────────

describe('isFormatActiveInSelection', () => {
Expand Down
40 changes: 40 additions & 0 deletions __tests__/utils/serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,44 @@ describe('serializer', () => {

expect(output).toBe('<h3>Title</h3>\n<p>Paragraph</p>');
});

it('serializes list items as grouped markdown lists', () => {
const output = serializeSegments(
[
createSegment('One', { listType: 'bullet' }),
createSegment('\n'),
createSegment('Two', { listType: 'bullet' }),
],
'markdown',
);

expect(output).toBe('- One\n- Two');
});

it('serializes links and images', () => {
const output = serializeSegments(
[
createSegment('Docs', { link: 'https://openai.com' }),
createSegment('\n'),
createSegment('[Image: Hero]', {
imageSrc: 'https://cdn.test/hero.png',
imageAlt: 'Hero',
}),
],
'markdown',
);

expect(output).toBe(
'[Docs](https://openai.com)\n![Hero](https://cdn.test/hero.png)',
);
});

it('uses html block fallback for markdown alignment', () => {
const output = serializeSegments(
[createSegment('Centered', { textAlign: 'center' })],
'markdown',
);

expect(output).toBe('<p style="text-align: center">Centered</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.4",
"version": "1.0.5",
"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
Loading
Loading