Skip to content
Open
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
48 changes: 47 additions & 1 deletion INPUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,12 +542,58 @@ Composer-specific props:
| `mergeStyle` | `true` | Merge preview styles with defaults instead of replacing them |
| `onLinkPress` | default link opener | Override link handling in preview |

## Rich Text Mode (`MarkdownEditor`)

`MarkdownEditor` combines the plain-text input with a WYSIWYG surface and a built-in mode toggle. The value is markdown in both modes; the rich mode hides inline markers and heading prefixes, shows list bullets, and styles bold, italic, underline, strikethrough and inline code in place.

```tsx
import React, {useState} from 'react';
import {MarkdownEditor, type MarkdownEditorMode} from '@ronradtke/react-native-markdown-display';

export function Composer() {
const [value, setValue] = useState('');
const [mode, setMode] = useState<MarkdownEditorMode>('wysiwyg');

return (
<MarkdownEditor
mode={mode}
onChangeText={setValue}
onModeChange={setMode}
placeholder="Message..."
value={value}
/>
);
}
```

| Prop | Default | Description |
| --- | --- | --- |
| `value` | required | Markdown string; commit it synchronously, like a controlled `TextInput` |
| `onChangeText` | required | Called with the updated markdown |
| `mode` | internal, starts in `'plaintext'` | Controlled mode: `'plaintext'` or `'wysiwyg'` |
| `onModeChange` | `undefined` | Called when the built-in toggle changes the mode |
| `showModeToggle` | `true` | Renders the plain/rich toggle |
| `toolbarItems` | expanded toolbar | Toolbar items for both modes |
| `formatStyles` | built-in | Per-format `TextStyle` overrides for the rich surface |
| `editorStyle` / `textStyle` / `containerStyle` | `undefined` | Styles for the surface, its text and the outer container |
| `resolveCommandPayload` | `undefined` | Async or sync payload resolver used before applying a command |

What the rich mode guarantees:

- Typed and pasted text reaches `value` unchanged. Identifiers such as `my_var`, expressions such as `2 * 3 * 4`, paths, URLs and code are never rewritten.
- Markdown typed by hand takes effect as soon as it is valid: typing `**bold**` shows bold text, and the keystrokes stay in the value. Typing continues after the closed span.
- Lines you do not touch are never rewritten. Only the edited line is adjusted when a delimiter must move to stay valid.
- Enter continues bullet, ordered and quote prefixes and exits an empty one, as in plain-text mode.
- Toolbar commands format the selection, or the word around the caret when nothing is selected, and rewrite only those lines.

Quotes, tables, links, images, HTML and fenced code blocks are shown as literal text in rich mode. `MarkdownWysiwygEditor` is also exported on its own for custom layouts, together with the pure helpers `buildWysiwygModel`, `applyDisplayEdit`, `applyInlineFormatWysiwyg`, `displaySelectionToSource` and `sourceOffsetToDisplay`.

## Current Editing Model

The editor stays within standard markdown text and selection transforms. That keeps it portable and deterministic, but it also means:

- the stored value is always markdown text
- formatting is applied by inserting/removing markdown markers
- preview is the place where formatted output is rendered
- preview and the rich-text surface render formatted output; the value never changes shape

If you need architecture-level background, see [doc/markdown-editor-architecture.md](doc/markdown-editor-architecture.md).
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Markdown rendering and editing primitives for React Native, running only in JS.
- ⚡ **Native rendering pipeline**: markdown is parsed with `markdown-it`, normalized into an AST, and rendered with React Native components.
- 🧱 **No WebView dependency**: content participates in your React Native layout, styling, theming, navigation, and event handling.
- 🧩 **Typed customization surface**: override styles, render rules, link handling, parser behavior, and image handling with TypeScript-friendly APIs.
- ✍️ **Viewer and editor pieces**: use `<Markdown>` for display, `MarkdownStream` for streaming text, or `MarkdownTextInput` / `MarkdownComposer` for authoring.
- ✍️ **Viewer and editor pieces**: use `<Markdown>` for display, `MarkdownStream` for streaming text, or `MarkdownTextInput` / `MarkdownComposer` / `MarkdownEditor` for authoring.
- 🔌 **Extensible markdown support**: bring your own `markdown-it` instance or opt into bundled plugins such as underline.

This package is intended as a modern replacement for `react-native-markdown-renderer`, with stricter typing and a maintained native rendering architecture.
Expand Down Expand Up @@ -88,6 +88,7 @@ export default function App(): React.JSX.Element {
| ⏱️ Render actively streaming markdown text | `MarkdownStream` |
| ⌨️ Add a markdown-aware text input | `MarkdownTextInput` |
| 🧰 Ship an opinionated markdown composer with toolbar and preview | `MarkdownComposer` |
| 🖋️ Edit markdown in a rich-text (WYSIWYG) surface with a plain-text toggle | `MarkdownEditor` |
| 🔎 Preview an editor value with the same renderer | `MarkdownPreview` |

## 📝 Supported Markdown
Expand Down Expand Up @@ -118,7 +119,7 @@ The viewer is designed to be customized at the right layer:
## 📚 Documentation

- [Viewer guide](VIEWER.md): rendering, styles, custom rules, `MarkdownIt`, preprocessing, and streaming
- [Input guide](INPUT.md): `MarkdownTextInput`, `MarkdownComposer`, toolbars, prompts, shortcuts, and previews
- [Input guide](INPUT.md): `MarkdownTextInput`, `MarkdownComposer`, `MarkdownEditor` rich text mode, toolbars, prompts, shortcuts, and previews
- [Editor architecture note](doc/markdown-editor-architecture.md): internal editor model and design notes
- [Example app](example/README.md): local example project

Expand Down
2 changes: 2 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ This library serves a dual role in an AI chat interface (similar to Claude or Ch
| Table dialog | Done | Built-in native `Alert`-based prompt; dimension validation |
| `onCommand` hook | Done | Intercept any toolbar command before it applies |
| `toolbarItems` prop | Done | Restrict or reorder toolbar buttons |
| `MarkdownEditor` (rich text mode) | Model done, device validation pending | Native `TextInput` with hidden markers; CommonMark-faithful parser shared with the renderer's rules; typed/pasted text reaches `value` unchanged; see `doc/wysiwyg-device-checklist.md` |
| `MarkdownWysiwygEditor` | Same | Standalone rich surface used by `MarkdownEditor` |

### Example App

Expand Down
171 changes: 171 additions & 0 deletions __tests__/MarkdownEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React from 'react';
import {TextInput} from 'react-native';
import renderer from 'react-test-renderer';

import {EditorToolbar, MarkdownEditor} from '../src';
import type {MarkdownEditorMode, MarkdownTextInputCommandPayload} from '../src';

const findPressableByLabel = (
tree: renderer.ReactTestRenderer,
label: string,
): renderer.ReactTestInstance =>
tree.root.find(
(node) =>
node.props.accessibilityLabel === label &&
typeof node.props.onPress === 'function',
);

const renderEditor = (
props: Partial<React.ComponentProps<typeof MarkdownEditor>> = {},
): {onChangeText: jest.Mock; tree: renderer.ReactTestRenderer} => {
const onChangeText = jest.fn();
let tree: renderer.ReactTestRenderer | undefined;

renderer.act(() => {
tree = renderer.create(
<MarkdownEditor onChangeText={onChangeText} value="" {...props} />,
);
});

if (!tree) {
throw new Error('Failed to render MarkdownEditor');
}

return {onChangeText, tree};
};

describe('MarkdownEditor', () => {
test.each<MarkdownEditorMode>(['plaintext', 'wysiwyg'])('pending commands preserve intervening edits in %s', async (mode) => {
let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {};
const pendingPayload = new Promise<MarkdownTextInputCommandPayload | null>((resolve) => { resolvePayload = resolve; });
const resolveCommandPayload = () => pendingPayload;
const {tree, onChangeText} = renderEditor({mode, value: 'Hello', resolveCommandPayload});
let pendingCommand: Promise<void> | undefined;

renderer.act(() => { pendingCommand = tree.root.findByType(EditorToolbar).props.onCommand('link'); });
renderer.act(() => {
tree.update(<MarkdownEditor mode={mode} value="Hello!" onChangeText={onChangeText} resolveCommandPayload={resolveCommandPayload} />);
});
await renderer.act(async () => {
resolvePayload({command: 'link', link: {url: 'https://example.com'}});
await pendingCommand;
});

expect(onChangeText.mock.calls[0]?.[0]).toContain('!');
});

test('clamps the selection after an external value change', async () => {
const {tree, onChangeText} = renderEditor({mode: 'wysiwyg', value: 'Hello world'});
renderer.act(() => {
tree.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: 2, end: 8}}});
});
renderer.act(() => {
tree.update(<MarkdownEditor mode="wysiwyg" value="Hi" onChangeText={onChangeText} />);
});
await renderer.act(async () => { await tree.root.findByType(EditorToolbar).props.onCommand('bold'); });

// The stale range is clamped to a caret, which formats the word around it.
expect(onChangeText).toHaveBeenCalledWith('**Hi**');
});

test.each([{editable: false}, {readOnly: true}])('disables commands and custom actions with %s', async (props) => {
const {tree, onChangeText} = renderEditor({mode: 'wysiwyg', value: 'Hello', ...props});
const bold = findPressableByLabel(tree, 'Bold');
expect(bold.props.disabled).toBe(true);
await renderer.act(async () => {
bold.props.onPress();
await tree.root.findByType(EditorToolbar).props.onCommand('bold');
tree.root.findByType(EditorToolbar).props.onAction({type: 'insert', markdown: '!'});
});

expect(onChangeText).not.toHaveBeenCalled();
});

test.each(['disabled', 'unmounted'])('does not apply a pending command after the editor is %s', async (change) => {
let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {};
const pendingPayload = new Promise<MarkdownTextInputCommandPayload | null>((resolve) => { resolvePayload = resolve; });
const {tree, onChangeText} = renderEditor({value: 'Hello', resolveCommandPayload: () => pendingPayload});
let pendingCommand: Promise<void> | undefined;
renderer.act(() => { pendingCommand = tree.root.findByType(EditorToolbar).props.onCommand('bold'); });
renderer.act(() => {
if (change === 'disabled') {
tree.update(<MarkdownEditor editable={false} value="Hello" onChangeText={onChangeText} />);
} else {
tree.unmount();
}
});
await renderer.act(async () => {
resolvePayload({command: 'bold'});
await pendingCommand;
});

expect(onChangeText).not.toHaveBeenCalled();
});

test('translates a selection when the parent changes the controlled mode', () => {
const {tree, onChangeText} = renderEditor({mode: 'plaintext', value: '**Hello** world'});
renderer.act(() => {
tree.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: 2, end: 7}}});
});
renderer.act(() => {
tree.update(<MarkdownEditor mode="wysiwyg" value="**Hello** world" onChangeText={onChangeText} />);
});
expect(tree.root.findByType(TextInput).props.selection).toEqual({start: 0, end: 5});
renderer.act(() => {
tree.update(<MarkdownEditor mode="plaintext" value="**Hello** world" onChangeText={onChangeText} />);
});
expect(tree.root.findByType(TextInput).props.selection).toEqual({start: 2, end: 7});
});

test('uses the resolved command in rich mode', async () => {
const {tree, onChangeText} = renderEditor({mode: 'wysiwyg', value: 'Hello', resolveCommandPayload: () => ({command: 'italic'})});
renderer.act(() => {
tree.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: 0, end: 5}}});
});
await renderer.act(async () => { await tree.root.findByType(EditorToolbar).props.onCommand('bold'); });

expect(onChangeText).toHaveBeenCalledWith('_Hello_');
});

test('wraps the caret with markers in plain-text mode', async () => {
const {onChangeText, tree} = renderEditor({value: ''});

await renderer.act(async () => {
findPressableByLabel(tree, 'Bold').props.onPress();
});

expect(onChangeText).toHaveBeenCalledWith('**text**');
});

test('formats the display selection in wysiwyg mode', async () => {
const {onChangeText, tree} = renderEditor({
mode: 'wysiwyg',
value: 'Hello',
});

const input = tree.root.findByType(TextInput);

renderer.act(() => {
input.props.onSelectionChange({
nativeEvent: {selection: {end: 5, start: 0}},
});
});

await renderer.act(async () => {
findPressableByLabel(tree, 'Bold').props.onPress();
});

expect(onChangeText).toHaveBeenCalledWith('**Hello**');
});

test('renders the mode toggle and reports mode changes', () => {
const onModeChange = jest.fn();
const {tree} = renderEditor({onModeChange});

renderer.act(() => {
findPressableByLabel(tree, 'Rich text').props.onPress();
});

expect(onModeChange).toHaveBeenCalledWith('wysiwyg');
});
});
74 changes: 74 additions & 0 deletions __tests__/MarkdownTextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,82 @@ import renderer from 'react-test-renderer';
import {Text, TextInput} from 'react-native';

import {type MarkdownManagedTextInputProps, MarkdownTextInput} from '../src';
import type {MarkdownTextInputCommandPayload} from '../src';

const renderInput = (props: React.ComponentProps<typeof MarkdownTextInput>): renderer.ReactTestRenderer => {
let tree: renderer.ReactTestRenderer | undefined;
renderer.act(() => { tree = renderer.create(<MarkdownTextInput {...props} />); });
if (!tree) {
throw new Error('Failed to render MarkdownTextInput');
}
return tree;
};

describe('MarkdownTextInput', () => {
test('uses the current value, selection, and callbacks when a pending command resolves', async () => {
let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {};
const pendingPayload = new Promise<MarkdownTextInputCommandPayload | null>((resolve) => { resolvePayload = resolve; });
const oldOnChangeText = jest.fn();
const onChangeText = jest.fn();
const onCommand = jest.fn();
const tree = renderInput({value: 'Hello', onChangeText: oldOnChangeText, resolveCommandPayload: () => pendingPayload});
const button = tree.root.find((node) => node.props.accessibilityLabel === 'Bold' && typeof node.props.onPress === 'function');
renderer.act(() => { button.props.onPress(); });
renderer.act(() => {
tree.update(<MarkdownTextInput value="Hello!" selection={{start: 5, end: 6}} onChangeText={onChangeText} onCommand={onCommand} />);
});
await renderer.act(async () => {
resolvePayload({command: 'bold'});
await pendingPayload;
});

expect(oldOnChangeText).not.toHaveBeenCalled();
expect(onChangeText).toHaveBeenCalledWith('Hello**!**');
expect(onCommand).toHaveBeenCalledWith({command: 'bold'}, {value: 'Hello**!**', selection: {start: 7, end: 8}});
});

test.each([{editable: false}, {readOnly: true}])('disables toolbar commands and actions with %s', async (props) => {
const onChangeText = jest.fn();
const resolveCommandPayload = jest.fn();
const tree = renderInput({
...props,
value: 'Hello',
onChangeText,
resolveCommandPayload,
toolbarItems: [{command: 'bold', label: 'Bold'}, {action: {type: 'insert', markdown: '!'}, label: 'Insert'}],
});
const buttons = tree.root.findAll((node) => typeof node.props.onPress === 'function');
await renderer.act(async () => {
for (const button of buttons) {
expect(button.props.disabled).toBe(true);
button.props.onPress();
}
});
expect(resolveCommandPayload).not.toHaveBeenCalled();
expect(onChangeText).not.toHaveBeenCalled();
});

test.each(['disabled', 'unmounted'])('cancels pending input commands when %s', async (change) => {
let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {};
const pendingPayload = new Promise<MarkdownTextInputCommandPayload | null>((resolve) => { resolvePayload = resolve; });
const onChangeText = jest.fn();
const tree = renderInput({value: 'Hello', onChangeText, resolveCommandPayload: () => pendingPayload});
const button = tree.root.find((node) => node.props.accessibilityLabel === 'Bold' && typeof node.props.onPress === 'function');
renderer.act(() => { button.props.onPress(); });
renderer.act(() => {
if (change === 'disabled') {
tree.update(<MarkdownTextInput value="Hello" readOnly onChangeText={onChangeText} />);
} else {
tree.unmount();
}
});
await renderer.act(async () => {
resolvePayload({command: 'bold'});
await pendingPayload;
});
expect(onChangeText).not.toHaveBeenCalled();
});

test('uses the command payload resolver for link commands', async () => {
const onChangeText = jest.fn();
const resolveCommandPayload = jest.fn(async () => ({
Expand Down
Loading
Loading