diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml
index 51b405e..f182b14 100644
--- a/.github/workflows/publish-npm.yml
+++ b/.github/workflows/publish-npm.yml
@@ -20,7 +20,7 @@ jobs:
permissions:
contents: read
container:
- image: node:20-alpine
+ image: node:22.14.0-alpine
defaults:
run:
shell: sh
@@ -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
diff --git a/__tests__/components/RichTextInput.test.tsx b/__tests__/components/RichTextInput.test.tsx
index f95a91f..37ad14d 100644
--- a/__tests__/components/RichTextInput.test.tsx
+++ b/__tests__/components/RichTextInput.test.tsx
@@ -164,6 +164,11 @@ describe('RichTextInput', () => {
expect(getByPlaceholderText('Type...').props.scrollEnabled).toBe(false);
rerender();
+ const input = getByPlaceholderText('Type...');
+ fireEvent(input, 'contentSizeChange', {
+ nativeEvent: { contentSize: { height: 420, width: 200 } },
+ });
+
expect(getByPlaceholderText('Type...').props.scrollEnabled).toBe(true);
});
@@ -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(
+ ,
+ );
+
+ fireEvent.changeText(getByPlaceholderText('Type...'), 'Hello');
+ fireEvent.press(getByText('HTML'));
+
+ expect(getByText('HTML output')).toBeTruthy();
+ expect(getByText('
Hello
')).toBeTruthy();
+ });
+
+ it('switches between raw output and rendered preview from the toolbar', () => {
+ const { getByText, queryByText } = render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
,
diff --git a/__tests__/components/Toolbar.test.tsx b/__tests__/components/Toolbar.test.tsx
index c4e9e74..502da4a 100644
--- a/__tests__/components/Toolbar.test.tsx
+++ b/__tests__/components/Toolbar.test.tsx
@@ -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(),
@@ -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(
+ ,
+ );
+
+ fireEvent.press(getByText('HTML'));
+ fireEvent.press(getByText('View'));
+
+ expect(onOutputFormatChange).toHaveBeenCalledWith('html');
+ expect(onOutputPreviewModeChange).toHaveBeenCalledWith('rendered');
});
});
diff --git a/__tests__/hooks/useFormatting.test.ts b/__tests__/hooks/useFormatting.test.ts
index 172fa22..d066aed 100644
--- a/__tests__/hooks/useFormatting.test.ts
+++ b/__tests__/hooks/useFormatting.test.ts
@@ -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', () => {
diff --git a/__tests__/hooks/useRichText.test.ts b/__tests__/hooks/useRichText.test.ts
index 5c64dba..4f71da6 100644
--- a/__tests__/hooks/useRichText.test.ts
+++ b/__tests__/hooks/useRichText.test.ts
@@ -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());
@@ -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(
+ 'Centered copy
',
+ );
+ });
+ });
+
+ 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(
+ '',
+ );
+ });
+ });
+
// ─── Export / Import ─────────────────────────────────────────────────────
describe('getPlainText', () => {
diff --git a/__tests__/utils/formatter.test.ts b/__tests__/utils/formatter.test.ts
index e8243ca..21b7a38 100644
--- a/__tests__/utils/formatter.test.ts
+++ b/__tests__/utils/formatter.test.ts
@@ -2,6 +2,8 @@ import {
toggleFormatOnSelection,
setStyleOnSelection,
setHeadingOnLine,
+ setListTypeOnLine,
+ setTextAlignOnLine,
isFormatActiveInSelection,
getSelectionStyle,
} from '../../src/utils/formatter';
@@ -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', () => {
diff --git a/__tests__/utils/serializer.test.ts b/__tests__/utils/serializer.test.ts
index a5fb769..121c729 100644
--- a/__tests__/utils/serializer.test.ts
+++ b/__tests__/utils/serializer.test.ts
@@ -63,4 +63,44 @@ describe('serializer', () => {
expect(output).toBe('Title
\nParagraph
');
});
+
+ 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',
+ );
+ });
+
+ it('uses html block fallback for markdown alignment', () => {
+ const output = serializeSegments(
+ [createSegment('Centered', { textAlign: 'center' })],
+ 'markdown',
+ );
+
+ expect(output).toBe('Centered
');
+ });
});
diff --git a/package.json b/package.json
index 78b0450..c5df0bd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/RenderedOutput.tsx b/src/components/RenderedOutput.tsx
new file mode 100644
index 0000000..fb593c2
--- /dev/null
+++ b/src/components/RenderedOutput.tsx
@@ -0,0 +1,231 @@
+import React, { useMemo } from 'react';
+import { Image, StyleSheet, Text, View } from 'react-native';
+import type { FormatStyle, RichTextTheme, StyledSegment } from '../types';
+import { DEFAULT_THEME } from '../constants/defaultStyles';
+import { segmentToTextStyle } from '../utils/styleMapper';
+
+interface RenderedOutputProps {
+ segments: StyledSegment[];
+ theme?: RichTextTheme;
+}
+
+type LineFragment = Pick;
+
+export const RenderedOutput: React.FC = React.memo(
+ ({ segments, theme }) => {
+ const resolvedTheme = theme ?? DEFAULT_THEME;
+ const lines = useMemo(() => splitSegmentsByLine(segments), [segments]);
+ let orderedIndex = 0;
+
+ return (
+
+ {lines.map((line, lineIndex) => {
+ const listType = getLineStyle(line, 'listType');
+ const textAlign = getLineStyle(line, 'textAlign');
+ const marker =
+ listType === 'bullet'
+ ? '\u2022'
+ : listType === 'ordered'
+ ? `${orderedIndex + 1}.`
+ : undefined;
+
+ orderedIndex = listType === 'ordered' ? orderedIndex + 1 : 0;
+
+ const textFragments = line.filter(
+ (fragment) => !fragment.styles.imageSrc && fragment.text.length > 0,
+ );
+ const imageFragments = line.filter((fragment) => !!fragment.styles.imageSrc);
+ const contentAlignStyle =
+ textAlign === 'center'
+ ? styles.alignCenter
+ : textAlign === 'right'
+ ? styles.alignRight
+ : styles.alignLeft;
+
+ if (textFragments.length === 0 && imageFragments.length === 0) {
+ return (
+
+ );
+ }
+
+ const textNode =
+ textFragments.length > 0 ? (
+
+ {textFragments.map((fragment, fragmentIndex) => (
+
+ {fragment.text}
+
+ ))}
+
+ ) : null;
+
+ const imageNodes = imageFragments.map((fragment, fragmentIndex) => (
+
+
+
+ {fragment.styles.imageAlt ?? extractImageAlt(fragment.text)}
+
+
+ ));
+
+ const content = (
+
+ {textNode}
+ {imageNodes}
+
+ );
+
+ if (!marker) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+
+ {marker}
+
+ {content}
+
+ );
+ })}
+
+ );
+ },
+);
+
+RenderedOutput.displayName = 'RenderedOutput';
+
+function splitSegmentsByLine(segments: StyledSegment[]): LineFragment[][] {
+ const lines: LineFragment[][] = [[]];
+
+ for (const segment of segments) {
+ const parts = segment.text.split('\n');
+
+ parts.forEach((part, index) => {
+ if (part.length > 0 || segment.styles.imageSrc) {
+ lines[lines.length - 1]?.push({
+ text: part,
+ styles: { ...segment.styles },
+ });
+ }
+
+ if (index < parts.length - 1) {
+ lines.push([]);
+ }
+ });
+ }
+
+ return lines;
+}
+
+function getLineStyle(
+ line: LineFragment[],
+ key: K,
+): FormatStyle[K] {
+ for (const fragment of line) {
+ const value = fragment.styles[key];
+ if (value !== undefined) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+function extractImageAlt(text: string): string {
+ const normalized = text
+ .replace(/^\[Image:\s*/i, '')
+ .replace(/^\[Image\]/i, '')
+ .replace(/\]$/, '')
+ .trim();
+
+ return normalized.length > 0 ? normalized : 'image';
+}
+
+const styles = StyleSheet.create({
+ container: {
+ gap: 10,
+ },
+ line: {
+ width: '100%',
+ },
+ lineContent: {
+ width: '100%',
+ gap: 8,
+ },
+ listLine: {
+ width: '100%',
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: 8,
+ },
+ listMarker: {
+ minWidth: 20,
+ paddingTop: 1,
+ },
+ listContent: {
+ flex: 1,
+ },
+ alignLeft: {
+ alignItems: 'flex-start',
+ },
+ alignCenter: {
+ alignItems: 'center',
+ },
+ alignRight: {
+ alignItems: 'flex-end',
+ },
+ emptyLine: {
+ minHeight: 20,
+ width: '100%',
+ },
+ imageBlock: {
+ width: '100%',
+ gap: 6,
+ },
+ image: {
+ width: '100%',
+ height: 160,
+ borderRadius: 10,
+ backgroundColor: '#E5E7EB',
+ },
+ imageCaption: {
+ fontSize: 13,
+ opacity: 0.8,
+ },
+});
diff --git a/src/components/RichTextInput.tsx b/src/components/RichTextInput.tsx
index 341c607..a0bcbb4 100644
--- a/src/components/RichTextInput.tsx
+++ b/src/components/RichTextInput.tsx
@@ -1,4 +1,10 @@
-import React, { useEffect, useCallback, useMemo, useRef } from 'react';
+import React, {
+ useEffect,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import {
Animated,
Easing,
@@ -8,6 +14,7 @@ import {
TextInput,
View,
type NativeSyntheticEvent,
+ type TextInputContentSizeChangeEventData,
type TextInputSelectionChangeEventData,
} from 'react-native';
import type { RichTextInputProps } from '../types';
@@ -15,9 +22,10 @@ import { DEFAULT_THEME } from '../constants/defaultStyles';
import { useRichText } from '../hooks/useRichText';
import { segmentsToPlainText } from '../utils/parser';
import { serializeSegments } from '../utils/serializer';
+import { RenderedOutput } from './RenderedOutput';
import { Toolbar } from './Toolbar';
-const OUTPUT_PANEL_HEIGHT = 180;
+const DEFAULT_OUTPUT_PANEL_MAX_HEIGHT = 180;
const isJestRuntime =
typeof (
globalThis as {
@@ -43,8 +51,16 @@ export const RichTextInput: React.FC = ({
toolbarItems,
theme,
showOutputPreview = true,
- outputFormat = 'markdown',
+ outputFormat,
+ defaultOutputFormat = 'markdown',
+ outputPreviewMode,
+ defaultOutputPreviewMode = 'literal',
+ maxOutputHeight = DEFAULT_OUTPUT_PANEL_MAX_HEIGHT,
onChangeOutput,
+ onChangeOutputFormat,
+ onChangeOutputPreviewMode,
+ onRequestLink,
+ onRequestImage,
multiline = true,
minHeight = 120,
maxHeight,
@@ -55,6 +71,12 @@ export const RichTextInput: React.FC = ({
}) => {
const resolvedTheme = theme ?? DEFAULT_THEME;
const previewProgress = useRef(new Animated.Value(0)).current;
+ const [internalOutputFormat, setInternalOutputFormat] =
+ useState(defaultOutputFormat);
+ const [internalOutputPreviewMode, setInternalOutputPreviewMode] = useState(
+ defaultOutputPreviewMode,
+ );
+ const [contentHeight, setContentHeight] = useState(minHeight);
const { state, actions } = useRichText({
initialSegments,
@@ -67,15 +89,41 @@ export const RichTextInput: React.FC = ({
}, [actions, onReady]);
const plainText = segmentsToPlainText(state.segments);
+ const resolvedOutputFormat = outputFormat ?? internalOutputFormat;
+ const resolvedOutputPreviewMode =
+ outputPreviewMode ?? internalOutputPreviewMode;
+ const inputHeight = Math.max(
+ minHeight,
+ typeof maxHeight === 'number'
+ ? Math.min(contentHeight, maxHeight)
+ : contentHeight,
+ );
+ const shouldScrollInput =
+ typeof maxHeight === 'number' && contentHeight > maxHeight;
const serializedOutput = useMemo(
- () => serializeSegments(state.segments, outputFormat),
- [outputFormat, state.segments],
+ () => serializeSegments(state.segments, resolvedOutputFormat),
+ [resolvedOutputFormat, state.segments],
);
const shouldShowOutputPreview = showOutputPreview && plainText.length > 0;
+ const normalizedSelection = useMemo(
+ () => ({
+ start: Math.min(state.selection.start, state.selection.end),
+ end: Math.max(state.selection.start, state.selection.end),
+ }),
+ [state.selection.end, state.selection.start],
+ );
+ const selectedText = useMemo(
+ () => plainText.slice(normalizedSelection.start, normalizedSelection.end),
+ [normalizedSelection.end, normalizedSelection.start, plainText],
+ );
+ const selectionStyle = useMemo(
+ () => actions.getSelectionStyle(),
+ [actions, state.activeStyles, state.segments, state.selection],
+ );
useEffect(() => {
- onChangeOutput?.(serializedOutput, outputFormat);
- }, [onChangeOutput, outputFormat, serializedOutput]);
+ onChangeOutput?.(serializedOutput, resolvedOutputFormat);
+ }, [onChangeOutput, resolvedOutputFormat, serializedOutput]);
useEffect(() => {
if (isJestRuntime) {
@@ -99,30 +147,108 @@ export const RichTextInput: React.FC = ({
[actions],
);
+ const onContentSizeChange = useCallback(
+ (e: NativeSyntheticEvent) => {
+ setContentHeight(Math.ceil(e.nativeEvent.contentSize.height));
+ textInputProps?.onContentSizeChange?.(e);
+ },
+ [textInputProps],
+ );
+
+ const handleOutputFormatChange = useCallback(
+ (format: 'markdown' | 'html') => {
+ if (outputFormat === undefined) {
+ setInternalOutputFormat(format);
+ }
+
+ onChangeOutputFormat?.(format);
+ },
+ [onChangeOutputFormat, outputFormat],
+ );
+
+ const handleOutputPreviewModeChange = useCallback(
+ (mode: 'literal' | 'rendered') => {
+ if (outputPreviewMode === undefined) {
+ setInternalOutputPreviewMode(mode);
+ }
+
+ onChangeOutputPreviewMode?.(mode);
+ },
+ [onChangeOutputPreviewMode, outputPreviewMode],
+ );
+
+ const handleRequestLink = useCallback(() => {
+ if (onRequestLink) {
+ onRequestLink({
+ selectedText,
+ currentUrl: selectionStyle.link,
+ applyLink: actions.setLink,
+ });
+ return;
+ }
+
+ if (selectionStyle.link) {
+ actions.setLink(undefined);
+ return;
+ }
+
+ const detectedUrl = detectLinkTarget(selectedText);
+ if (detectedUrl) {
+ actions.setLink(detectedUrl);
+ }
+ }, [actions, onRequestLink, selectedText, selectionStyle.link]);
+
+ const handleRequestImage = useCallback(() => {
+ if (onRequestImage) {
+ onRequestImage({
+ insertImage: actions.insertImage,
+ });
+ return;
+ }
+
+ const detectedSource = detectImageSource(selectedText);
+ if (detectedSource) {
+ actions.insertImage(detectedSource, {
+ alt: selectedText.trim() || undefined,
+ });
+ }
+ }, [actions, onRequestImage, selectedText]);
+
+ const outputLabel = useMemo(() => {
+ const formatLabel = resolvedOutputFormat === 'html' ? 'HTML' : 'Markdown';
+
+ if (resolvedOutputPreviewMode === 'rendered') {
+ return `${formatLabel} preview`;
+ }
+
+ return `${formatLabel} output`;
+ }, [resolvedOutputFormat, resolvedOutputPreviewMode]);
+
const containerStyle = [
resolvedTheme.containerStyle ?? DEFAULT_THEME.containerStyle,
];
- const inputAreaStyle = [
- styles.inputArea,
- { minHeight },
- maxHeight ? { maxHeight } : undefined,
- ];
+ const inputAreaStyle = [styles.inputArea];
const inputStyle = [
styles.textInput,
resolvedTheme.baseTextStyle ?? DEFAULT_THEME.baseTextStyle,
resolvedTheme.inputStyle ?? DEFAULT_THEME.inputStyle,
+ { height: inputHeight },
textInputProps?.style,
];
const outputAnimatedStyle = {
maxHeight: previewProgress.interpolate({
inputRange: [0, 1],
- outputRange: [0, OUTPUT_PANEL_HEIGHT],
+ outputRange: [0, maxOutputHeight + 72],
}),
opacity: previewProgress,
marginTop: previewProgress.interpolate({
inputRange: [0, 1],
outputRange: [0, 12],
}),
+ marginBottom: previewProgress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, 16],
+ }),
transform: [
{
translateY: previewProgress.interpolate({
@@ -148,6 +274,12 @@ export const RichTextInput: React.FC = ({
state={state}
items={toolbarItems}
theme={resolvedTheme}
+ outputFormat={resolvedOutputFormat}
+ outputPreviewMode={resolvedOutputPreviewMode}
+ onOutputFormatChange={handleOutputFormatChange}
+ onOutputPreviewModeChange={handleOutputPreviewModeChange}
+ onRequestLink={handleRequestLink}
+ onRequestImage={handleRequestImage}
renderToolbar={renderToolbar}
/>
) : null;
@@ -180,6 +312,7 @@ export const RichTextInput: React.FC = ({
value={plainText}
onChangeText={actions.handleTextChange}
onSelectionChange={onSelectionChange}
+ onContentSizeChange={onContentSizeChange}
multiline={multiline}
placeholder={placeholder}
placeholderTextColor={
@@ -193,7 +326,7 @@ export const RichTextInput: React.FC = ({
resolvedTheme.colors?.cursor ?? DEFAULT_THEME.colors?.cursor
}
textAlignVertical="top"
- scrollEnabled={typeof maxHeight === 'number'}
+ scrollEnabled={shouldScrollInput}
/>
{showOutputPreview && (
@@ -203,12 +336,22 @@ export const RichTextInput: React.FC = ({
>
- {outputFormat === 'html' ? 'HTML output' : 'Markdown output'}
+ {outputLabel}
-
-
- {serializedOutput}
-
+
+ {resolvedOutputPreviewMode === 'rendered' ? (
+
+ ) : (
+
+ {serializedOutput}
+
+ )}
@@ -235,3 +378,38 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
});
+
+function detectLinkTarget(value: string): string | undefined {
+ const trimmed = value.trim();
+ if (trimmed.length === 0) {
+ return undefined;
+ }
+
+ if (/^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed)) {
+ return trimmed;
+ }
+
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
+ return `mailto:${trimmed}`;
+ }
+
+ if (/^[^\s]+\.[^\s]+$/.test(trimmed)) {
+ return `https://${trimmed}`;
+ }
+
+ return undefined;
+}
+
+function detectImageSource(value: string): string | undefined {
+ const normalized = detectLinkTarget(value);
+ if (!normalized) {
+ return undefined;
+ }
+
+ const candidate = normalized.replace(/^mailto:/i, '');
+ if (/\.(png|jpe?g|gif|webp|svg)(\?.*)?$/i.test(candidate)) {
+ return normalized;
+ }
+
+ return undefined;
+}
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index 1885d37..8f28587 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -14,7 +14,20 @@ import { ToolbarButton } from './ToolbarButton';
* - Horizontal scrolling for overflow
*/
export const Toolbar: React.FC = React.memo(
- ({ actions, state, items, theme, visible = true, renderToolbar }) => {
+ ({
+ actions,
+ state,
+ items,
+ theme,
+ visible = true,
+ outputFormat = 'markdown',
+ outputPreviewMode = 'literal',
+ onOutputFormatChange,
+ onOutputPreviewModeChange,
+ onRequestLink,
+ onRequestImage,
+ renderToolbar,
+ }) => {
const resolvedTheme = theme ?? DEFAULT_THEME;
const toolbarItems = items ?? DEFAULT_TOOLBAR_ITEMS;
@@ -33,12 +46,32 @@ export const Toolbar: React.FC = React.memo(
isActive = selectionStyle.heading === item.heading;
}
+ if (item.listType) {
+ isActive = selectionStyle.listType === item.listType;
+ }
+
+ if (item.textAlign) {
+ isActive = selectionStyle.textAlign === item.textAlign;
+ }
+
+ if (item.outputFormat) {
+ isActive = outputFormat === item.outputFormat;
+ }
+
+ if (item.outputPreviewMode) {
+ isActive = outputPreviewMode === item.outputPreviewMode;
+ }
+
+ if (item.actionType === 'link') {
+ isActive = !!selectionStyle.link;
+ }
+
return {
...item,
active: item.active ?? isActive,
};
});
- }, [actions, toolbarItems]);
+ }, [actions, outputFormat, outputPreviewMode, toolbarItems]);
// Custom render
if (renderToolbar) {
@@ -46,6 +79,13 @@ export const Toolbar: React.FC = React.memo(
items: enrichedItems,
state,
actions,
+ outputFormat,
+ outputPreviewMode,
+ onOutputFormatChange: onOutputFormatChange ?? (() => undefined),
+ onOutputPreviewModeChange:
+ onOutputPreviewModeChange ?? (() => undefined),
+ onRequestLink,
+ onRequestImage,
});
}
@@ -79,6 +119,18 @@ export const Toolbar: React.FC = React.memo(
actions.toggleFormat(item.format);
} else if (item.heading) {
actions.setHeading(item.heading);
+ } else if (item.listType) {
+ actions.setListType(item.listType);
+ } else if (item.textAlign) {
+ actions.setTextAlign(item.textAlign);
+ } else if (item.outputFormat) {
+ onOutputFormatChange?.(item.outputFormat);
+ } else if (item.outputPreviewMode) {
+ onOutputPreviewModeChange?.(item.outputPreviewMode);
+ } else if (item.actionType === 'link') {
+ onRequestLink?.();
+ } else if (item.actionType === 'image') {
+ onRequestImage?.();
}
}}
/>
diff --git a/src/constants/defaultStyles.d.ts b/src/constants/defaultStyles.d.ts
index 045fe60..540adb2 100644
--- a/src/constants/defaultStyles.d.ts
+++ b/src/constants/defaultStyles.d.ts
@@ -11,6 +11,7 @@ export declare const DEFAULT_COLORS: {
readonly toolbarBorder: "#E5E7EB";
readonly outputBackground: "#F8FAFC";
readonly outputLabel: "#475569";
+ readonly link: "#2563EB";
readonly cursor: "#6366F1";
readonly activeButtonBg: "#EEF2FF";
readonly codeBackground: "#F3F4F6";
@@ -34,7 +35,7 @@ export declare const DEFAULT_BASE_TEXT_STYLE: {
readonly fontFamily: undefined;
};
/**
- * Empty format style — no formatting applied.
+ * Empty format style — no formatting applied.
*/
export declare const EMPTY_FORMAT_STYLE: FormatStyle;
/**
diff --git a/src/constants/defaultStyles.ts b/src/constants/defaultStyles.ts
index 4c240a8..65e3fef 100644
--- a/src/constants/defaultStyles.ts
+++ b/src/constants/defaultStyles.ts
@@ -12,6 +12,7 @@ export const DEFAULT_COLORS = {
toolbarBorder: '#E5E7EB',
outputBackground: '#F8FAFC',
outputLabel: '#475569',
+ link: '#2563EB',
cursor: '#6366F1',
activeButtonBg: '#EEF2FF',
codeBackground: '#F3F4F6',
@@ -50,6 +51,11 @@ export const EMPTY_FORMAT_STYLE: FormatStyle = {
backgroundColor: undefined,
fontSize: undefined,
heading: undefined,
+ listType: undefined,
+ textAlign: undefined,
+ link: undefined,
+ imageSrc: undefined,
+ imageAlt: undefined,
};
/**
@@ -108,6 +114,9 @@ export const DEFAULT_THEME: RichTextTheme = {
color: DEFAULT_COLORS.text,
fontFamily: 'monospace',
},
+ renderedOutputStyle: {
+ gap: 10,
+ },
toolbarStyle: {
flexDirection: 'row',
alignItems: 'center',
@@ -150,6 +159,7 @@ export const DEFAULT_THEME: RichTextTheme = {
placeholder: DEFAULT_COLORS.placeholder,
toolbarBackground: DEFAULT_COLORS.toolbarBackground,
toolbarBorder: DEFAULT_COLORS.toolbarBorder,
+ link: DEFAULT_COLORS.link,
cursor: DEFAULT_COLORS.cursor,
},
};
@@ -166,4 +176,15 @@ export const DEFAULT_TOOLBAR_ITEMS: ToolbarItem[] = [
{ id: 'h1', label: 'H1', heading: 'h1' },
{ id: 'h2', label: 'H2', heading: 'h2' },
{ id: 'h3', label: 'H3', heading: 'h3' },
+ { id: 'bullet', label: '\u2022', listType: 'bullet' },
+ { id: 'ordered', label: '1.', listType: 'ordered' },
+ { id: 'link', label: 'Link', actionType: 'link' },
+ { id: 'image', label: 'Img', actionType: 'image' },
+ { id: 'align-left', label: 'L', textAlign: 'left' },
+ { id: 'align-center', label: 'C', textAlign: 'center' },
+ { id: 'align-right', label: 'R', textAlign: 'right' },
+ { id: 'format-markdown', label: 'MD', outputFormat: 'markdown' },
+ { id: 'format-html', label: 'HTML', outputFormat: 'html' },
+ { id: 'preview-literal', label: 'Raw', outputPreviewMode: 'literal' },
+ { id: 'preview-rendered', label: 'View', outputPreviewMode: 'rendered' },
];
diff --git a/src/hooks/useFormatting.ts b/src/hooks/useFormatting.ts
index 17dbbc2..3228ae3 100644
--- a/src/hooks/useFormatting.ts
+++ b/src/hooks/useFormatting.ts
@@ -4,12 +4,16 @@ import type {
FormatType,
FormatStyle,
HeadingLevel,
+ ListType,
SelectionRange,
+ TextAlign,
} from '../types';
import {
toggleFormatOnSelection,
setStyleOnSelection,
setHeadingOnLine,
+ setListTypeOnLine,
+ setTextAlignOnLine,
isFormatActiveInSelection,
getSelectionStyle,
} from '../utils/formatter';
@@ -78,14 +82,66 @@ export function useFormatting({
const setHeading = useCallback(
(level: HeadingLevel) => {
+ const currentHeading =
+ selection.start === selection.end
+ ? getSelectionStyle(segments, selection).heading ?? activeStyles.heading
+ : getSelectionStyle(segments, selection).heading;
+ const nextHeading: HeadingLevel =
+ currentHeading === level ? 'none' : level;
+
+ if (selection.start === selection.end) {
+ onActiveStylesChange({
+ ...activeStyles,
+ heading: nextHeading === 'none' ? undefined : nextHeading,
+ listType:
+ nextHeading === 'none' ? activeStyles.listType : undefined,
+ });
+ }
+
+ const newSegments = setHeadingOnLine(segments, selection, nextHeading);
+ onSegmentsChange(newSegments);
+ },
+ [
+ activeStyles,
+ onActiveStylesChange,
+ onSegmentsChange,
+ segments,
+ selection,
+ ],
+ );
+
+ const setListType = useCallback(
+ (listType: ListType) => {
+ if (selection.start === selection.end) {
+ onActiveStylesChange({
+ ...activeStyles,
+ listType: listType === 'none' ? undefined : listType,
+ heading: listType === 'none' ? activeStyles.heading : undefined,
+ });
+ }
+
+ const newSegments = setListTypeOnLine(segments, selection, listType);
+ onSegmentsChange(newSegments);
+ },
+ [
+ activeStyles,
+ onActiveStylesChange,
+ onSegmentsChange,
+ segments,
+ selection,
+ ],
+ );
+
+ const setTextAlign = useCallback(
+ (textAlign: TextAlign) => {
if (selection.start === selection.end) {
onActiveStylesChange({
...activeStyles,
- heading: level === 'none' ? undefined : level,
+ textAlign,
});
}
- const newSegments = setHeadingOnLine(segments, selection, level);
+ const newSegments = setTextAlignOnLine(segments, selection, textAlign);
onSegmentsChange(newSegments);
},
[
@@ -97,6 +153,21 @@ export function useFormatting({
],
);
+ const setLink = useCallback(
+ (url?: string) => {
+ if (selection.start === selection.end) {
+ onActiveStylesChange({
+ ...activeStyles,
+ link: url,
+ });
+ } else {
+ const newSegments = setStyleOnSelection(segments, selection, 'link', url);
+ onSegmentsChange(newSegments);
+ }
+ },
+ [segments, selection, activeStyles, onSegmentsChange, onActiveStylesChange],
+ );
+
const setColor = useCallback(
(color: string) => {
setStyleProperty('color', color);
@@ -139,6 +210,9 @@ export function useFormatting({
toggleFormat,
setStyleProperty,
setHeading,
+ setListType,
+ setTextAlign,
+ setLink,
setColor,
setBackgroundColor,
setFontSize,
diff --git a/src/hooks/useRichText.ts b/src/hooks/useRichText.ts
index 177f772..8f7c70e 100644
--- a/src/hooks/useRichText.ts
+++ b/src/hooks/useRichText.ts
@@ -2,10 +2,12 @@ import { useState, useCallback, useRef } from 'react';
import type {
StyledSegment,
FormatStyle,
+ ListType,
OutputFormat,
SelectionRange,
RichTextState,
RichTextActions,
+ TextAlign,
UseRichTextReturn,
} from '../types';
import { EMPTY_FORMAT_STYLE } from '../constants/defaultStyles';
@@ -91,10 +93,11 @@ export function useRichText(
(newText: string) => {
const currentSegments = segmentsRef.current;
const currentSelection = selectionRef.current;
- const currentActiveStyles =
+ const currentActiveStyles = sanitizeTypingStyles(
currentSelection.start === currentSelection.end
? activeStylesRef.current
- : getSelectionStyle(currentSegments, currentSelection);
+ : getSelectionStyle(currentSegments, currentSelection),
+ );
const newSegments = reconcileTextChange(
currentSegments,
@@ -135,7 +138,7 @@ export function useRichText(
);
if (segmentsRef.current.length > 0) {
const seg = segmentsRef.current[pos.segmentIndex];
- setActiveStyles({ ...seg.styles });
+ setActiveStyles(sanitizeTypingStyles(seg.styles));
}
}
},
@@ -164,15 +167,18 @@ export function useRichText(
const safeSegments =
newSegments.length > 0 ? newSegments : [createSegment('')];
updateSegments(safeSegments);
+ handleSelectionChange({ start: 0, end: 0 });
+ setActiveStyles({ ...EMPTY_FORMAT_STYLE });
},
- [updateSegments],
+ [handleSelectionChange, updateSegments],
);
const clear = useCallback(() => {
updateSegments([createSegment('')]);
+ handleSelectionChange({ start: 0, end: 0 });
setActiveStyles({ ...EMPTY_FORMAT_STYLE });
preserveActiveStylesRef.current = false;
- }, [updateSegments]);
+ }, [handleSelectionChange, updateSegments]);
const toggleFormat = useCallback(
(format) => {
@@ -204,6 +210,36 @@ export function useRichText(
[formatting],
);
+ const setListType = useCallback(
+ (type: ListType) => {
+ if (selectionRef.current.start === selectionRef.current.end) {
+ preserveActiveStylesRef.current = true;
+ }
+ formatting.setListType(type);
+ },
+ [formatting],
+ );
+
+ const setTextAlign = useCallback(
+ (align: TextAlign) => {
+ if (selectionRef.current.start === selectionRef.current.end) {
+ preserveActiveStylesRef.current = true;
+ }
+ formatting.setTextAlign(align);
+ },
+ [formatting],
+ );
+
+ const setLink = useCallback(
+ (url) => {
+ if (selectionRef.current.start === selectionRef.current.end) {
+ preserveActiveStylesRef.current = true;
+ }
+ formatting.setLink(url);
+ },
+ [formatting],
+ );
+
const setColor = useCallback(
(color) => {
if (selectionRef.current.start === selectionRef.current.end) {
@@ -234,6 +270,41 @@ export function useRichText(
[formatting],
);
+ const insertImage = useCallback(
+ (source, options) => {
+ const currentSegments = segmentsRef.current;
+ const currentSelection = selectionRef.current;
+ const plainText = segmentsToPlainText(currentSegments);
+ const start = Math.min(currentSelection.start, currentSelection.end);
+ const end = Math.max(currentSelection.start, currentSelection.end);
+ const placeholder =
+ options?.placeholder ?? buildImagePlaceholder(source, options?.alt);
+ const nextText = `${plainText.slice(0, start)}${placeholder}${plainText.slice(end)}`;
+ const insertionStyles = sanitizeTypingStyles(
+ start === end
+ ? activeStylesRef.current
+ : getSelectionStyle(currentSegments, currentSelection),
+ );
+ const nextSegments = reconcileTextChange(
+ currentSegments,
+ nextText,
+ {
+ ...insertionStyles,
+ imageSrc: source,
+ imageAlt: options?.alt,
+ },
+ );
+
+ updateSegments(nextSegments);
+ handleSelectionChange({
+ start: start + placeholder.length,
+ end: start + placeholder.length,
+ });
+ preserveActiveStylesRef.current = false;
+ },
+ [handleSelectionChange, updateSegments],
+ );
+
// ─── Build Return Value ──────────────────────────────────────────────────
const state: RichTextState = {
@@ -246,6 +317,10 @@ export function useRichText(
toggleFormat,
setStyleProperty,
setHeading,
+ setListType,
+ setTextAlign,
+ setLink,
+ insertImage,
setColor,
setBackgroundColor,
setFontSize,
@@ -262,3 +337,24 @@ export function useRichText(
return { state, actions };
}
+
+function sanitizeTypingStyles(style: FormatStyle): FormatStyle {
+ return {
+ ...style,
+ imageSrc: undefined,
+ imageAlt: undefined,
+ };
+}
+
+function buildImagePlaceholder(source: string, alt?: string): string {
+ if (alt && alt.trim().length > 0) {
+ return `[Image: ${alt.trim()}]`;
+ }
+
+ const fileName = source.split('/').pop()?.split('?')[0]?.trim();
+ if (fileName) {
+ return `[Image: ${fileName}]`;
+ }
+
+ return '[Image]';
+}
diff --git a/src/index.d.ts b/src/index.d.ts
index 2e29b32..25d483d 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -13,4 +13,4 @@ export { toggleFormatOnSelection, setStyleOnSelection, setHeadingOnLine, isForma
export { formatStyleToTextStyle, segmentToTextStyle, segmentsToTextStyles, } from './utils/styleMapper';
export { serializeSegments, segmentsToMarkdown, segmentsToHTML, } from './utils/serializer';
export { DEFAULT_COLORS, DEFAULT_THEME, DEFAULT_TOOLBAR_ITEMS, DEFAULT_BASE_TEXT_STYLE, HEADING_FONT_SIZES, EMPTY_FORMAT_STYLE, } from './constants/defaultStyles';
-export type { FormatType, HeadingLevel, ListType, OutputFormat, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, ToolbarButtonRenderProps, ToolbarRenderProps, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types';
+export type { FormatType, HeadingLevel, ListType, TextAlign, OutputFormat, OutputPreviewMode, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, ToolbarButtonRenderProps, ToolbarRenderProps, LinkRequestPayload, ImageRequestPayload, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types';
diff --git a/src/index.ts b/src/index.ts
index e3c489f..3478169 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -58,7 +58,9 @@ export type {
FormatType,
HeadingLevel,
ListType,
+ TextAlign,
OutputFormat,
+ OutputPreviewMode,
FormatStyle,
StyledSegment,
SelectionRange,
@@ -69,6 +71,8 @@ export type {
ToolbarItem,
ToolbarButtonRenderProps,
ToolbarRenderProps,
+ LinkRequestPayload,
+ ImageRequestPayload,
OverlayTextProps,
ToolbarButtonProps,
ToolbarProps,
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index c8c6ce1..7cfcce8 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -11,10 +11,18 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
* List type for a line/paragraph.
*/
export type ListType = 'bullet' | 'ordered' | 'none';
+/**
+ * Paragraph alignment presets.
+ */
+export type TextAlign = 'left' | 'center' | 'right';
/**
* Serialized output formats supported by the editor.
*/
export type OutputFormat = 'markdown' | 'html';
+/**
+ * Output preview modes supported by the editor.
+ */
+export type OutputPreviewMode = 'literal' | 'rendered';
/**
* Inline formatting styles attached to a text segment.
*/
@@ -28,6 +36,11 @@ export interface FormatStyle {
backgroundColor?: string;
fontSize?: number;
heading?: HeadingLevel;
+ listType?: ListType;
+ textAlign?: TextAlign;
+ link?: string;
+ imageSrc?: string;
+ imageAlt?: string;
}
/**
* A segment of text with uniform formatting.
@@ -69,6 +82,17 @@ export interface RichTextActions {
setStyleProperty: (key: K, value: FormatStyle[K]) => void;
/** Apply a heading level to the current line. */
setHeading: (level: HeadingLevel) => void;
+ /** Apply a list style to the current line. */
+ setListType: (type: ListType) => void;
+ /** Apply paragraph alignment to the current line. */
+ setTextAlign: (align: TextAlign) => void;
+ /** Apply or clear a hyperlink on the current selection. */
+ setLink: (url?: string) => void;
+ /** Insert an image placeholder into the document. */
+ insertImage: (source: string, options?: {
+ alt?: string;
+ placeholder?: string;
+ }) => void;
/** Set the text color for the current selection. */
setColor: (color: string) => void;
/** Set the background color for the current selection. */
@@ -119,6 +143,8 @@ export interface RichTextTheme {
outputLabelStyle?: TextStyle;
/** Style for the serialized output text. */
outputTextStyle?: TextStyle;
+ /** Style for the rendered output preview content. */
+ renderedOutputStyle?: ViewStyle;
/** Style for the toolbar container. */
toolbarStyle?: ViewStyle;
/** Style for toolbar buttons. */
@@ -145,6 +171,8 @@ export interface RichTextTheme {
toolbarBackground?: string;
/** Toolbar border color. */
toolbarBorder?: string;
+ /** Default link color. */
+ link?: string;
/** Cursor / caret color. */
cursor?: ColorValue;
};
@@ -161,6 +189,16 @@ export interface ToolbarItem {
format?: FormatType;
/** The heading level this button sets. */
heading?: HeadingLevel;
+ /** The list type this button sets. */
+ listType?: ListType;
+ /** The alignment this button sets. */
+ textAlign?: TextAlign;
+ /** The output format this button toggles to. */
+ outputFormat?: OutputFormat;
+ /** The output preview mode this button toggles to. */
+ outputPreviewMode?: OutputPreviewMode;
+ /** Special toolbar action. */
+ actionType?: 'link' | 'image';
/** Custom action handler (overrides default behavior). */
onPress?: () => void;
/** Whether this item is currently active. */
@@ -183,6 +221,33 @@ export interface ToolbarRenderProps {
items: ToolbarItem[];
state: RichTextState;
actions: RichTextActions;
+ outputFormat: OutputFormat;
+ outputPreviewMode: OutputPreviewMode;
+ onOutputFormatChange: (format: OutputFormat) => void;
+ onOutputPreviewModeChange: (mode: OutputPreviewMode) => void;
+ onRequestLink?: () => void;
+ onRequestImage?: () => void;
+}
+/**
+ * Payload passed when the built-in link button requests a URL.
+ */
+export interface LinkRequestPayload {
+ /** Selected plain text at the time of the request. */
+ selectedText: string;
+ /** Existing URL on the selection, when present. */
+ currentUrl?: string;
+ /** Apply or clear the URL on the current selection. */
+ applyLink: (url?: string) => void;
+}
+/**
+ * Payload passed when the built-in image button requests an image source.
+ */
+export interface ImageRequestPayload {
+ /** Insert an image placeholder into the document. */
+ insertImage: (source: string, options?: {
+ alt?: string;
+ placeholder?: string;
+ }) => void;
}
/**
* Props for the OverlayText component.
@@ -224,6 +289,18 @@ export interface ToolbarProps {
theme?: RichTextTheme;
/** Whether to show the toolbar. */
visible?: boolean;
+ /** Currently selected serialized output format. */
+ outputFormat?: OutputFormat;
+ /** Currently selected preview mode. */
+ outputPreviewMode?: OutputPreviewMode;
+ /** Called when the output format changes from the toolbar. */
+ onOutputFormatChange?: (format: OutputFormat) => void;
+ /** Called when the preview mode changes from the toolbar. */
+ onOutputPreviewModeChange?: (mode: OutputPreviewMode) => void;
+ /** Called when the link button is pressed. */
+ onRequestLink?: () => void;
+ /** Called when the image button is pressed. */
+ onRequestImage?: () => void;
/** Custom render function for the entire toolbar. */
renderToolbar?: (props: ToolbarRenderProps) => React.ReactElement | null;
}
@@ -253,10 +330,26 @@ export interface RichTextInputProps {
theme?: RichTextTheme;
/** Whether to show the serialized output preview below the input. */
showOutputPreview?: boolean;
- /** Format used for the serialized output preview. */
+ /** Controlled format used for the serialized output preview. */
outputFormat?: OutputFormat;
+ /** Initial format used for the serialized output preview. */
+ defaultOutputFormat?: OutputFormat;
+ /** Controlled preview mode for the output panel. */
+ outputPreviewMode?: OutputPreviewMode;
+ /** Initial preview mode for the output panel. */
+ defaultOutputPreviewMode?: OutputPreviewMode;
+ /** Maximum height for the output preview panel. */
+ maxOutputHeight?: number;
/** Callback when the serialized output changes. */
onChangeOutput?: (output: string, format: OutputFormat) => void;
+ /** Callback when the output format changes. */
+ onChangeOutputFormat?: (format: OutputFormat) => void;
+ /** Callback when the output preview mode changes. */
+ onChangeOutputPreviewMode?: (mode: OutputPreviewMode) => void;
+ /** Invoked when the built-in link button needs a URL. */
+ onRequestLink?: (payload: LinkRequestPayload) => void;
+ /** Invoked when the built-in image button needs an image source. */
+ onRequestImage?: (payload: ImageRequestPayload) => void;
/** Whether multiline input is enabled. */
multiline?: boolean;
/** Minimum height for the input area. */
diff --git a/src/types/index.ts b/src/types/index.ts
index 4d98fb3..8718c39 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -22,11 +22,21 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
*/
export type ListType = 'bullet' | 'ordered' | 'none';
+/**
+ * Paragraph alignment presets.
+ */
+export type TextAlign = 'left' | 'center' | 'right';
+
/**
* Serialized output formats supported by the editor.
*/
export type OutputFormat = 'markdown' | 'html';
+/**
+ * Output preview modes supported by the editor.
+ */
+export type OutputPreviewMode = 'literal' | 'rendered';
+
// ─── Style Types ─────────────────────────────────────────────────────────────
/**
@@ -42,6 +52,11 @@ export interface FormatStyle {
backgroundColor?: string;
fontSize?: number;
heading?: HeadingLevel;
+ listType?: ListType;
+ textAlign?: TextAlign;
+ link?: string;
+ imageSrc?: string;
+ imageAlt?: string;
}
/**
@@ -96,6 +111,20 @@ export interface RichTextActions {
) => void;
/** Apply a heading level to the current line. */
setHeading: (level: HeadingLevel) => void;
+ /** Apply a list style to the current line. */
+ setListType: (type: ListType) => void;
+ /** Apply paragraph alignment to the current line. */
+ setTextAlign: (align: TextAlign) => void;
+ /** Apply or clear a hyperlink on the current selection. */
+ setLink: (url?: string) => void;
+ /** Insert an image placeholder into the document. */
+ insertImage: (
+ source: string,
+ options?: {
+ alt?: string;
+ placeholder?: string;
+ },
+ ) => void;
/** Set the text color for the current selection. */
setColor: (color: string) => void;
/** Set the background color for the current selection. */
@@ -152,6 +181,8 @@ export interface RichTextTheme {
outputLabelStyle?: TextStyle;
/** Style for the serialized output text. */
outputTextStyle?: TextStyle;
+ /** Style for the rendered output preview content. */
+ renderedOutputStyle?: ViewStyle;
/** Style for the toolbar container. */
toolbarStyle?: ViewStyle;
/** Style for toolbar buttons. */
@@ -178,6 +209,8 @@ export interface RichTextTheme {
toolbarBackground?: string;
/** Toolbar border color. */
toolbarBorder?: string;
+ /** Default link color. */
+ link?: string;
/** Cursor / caret color. */
cursor?: ColorValue;
};
@@ -197,6 +230,16 @@ export interface ToolbarItem {
format?: FormatType;
/** The heading level this button sets. */
heading?: HeadingLevel;
+ /** The list type this button sets. */
+ listType?: ListType;
+ /** The alignment this button sets. */
+ textAlign?: TextAlign;
+ /** The output format this button toggles to. */
+ outputFormat?: OutputFormat;
+ /** The output preview mode this button toggles to. */
+ outputPreviewMode?: OutputPreviewMode;
+ /** Special toolbar action. */
+ actionType?: 'link' | 'image';
/** Custom action handler (overrides default behavior). */
onPress?: () => void;
/** Whether this item is currently active. */
@@ -221,6 +264,38 @@ export interface ToolbarRenderProps {
items: ToolbarItem[];
state: RichTextState;
actions: RichTextActions;
+ outputFormat: OutputFormat;
+ outputPreviewMode: OutputPreviewMode;
+ onOutputFormatChange: (format: OutputFormat) => void;
+ onOutputPreviewModeChange: (mode: OutputPreviewMode) => void;
+ onRequestLink?: () => void;
+ onRequestImage?: () => void;
+}
+
+/**
+ * Payload passed when the built-in link button requests a URL.
+ */
+export interface LinkRequestPayload {
+ /** Selected plain text at the time of the request. */
+ selectedText: string;
+ /** Existing URL on the selection, when present. */
+ currentUrl?: string;
+ /** Apply or clear the URL on the current selection. */
+ applyLink: (url?: string) => void;
+}
+
+/**
+ * Payload passed when the built-in image button requests an image source.
+ */
+export interface ImageRequestPayload {
+ /** Insert an image placeholder into the document. */
+ insertImage: (
+ source: string,
+ options?: {
+ alt?: string;
+ placeholder?: string;
+ },
+ ) => void;
}
// ─── Component Props ─────────────────────────────────────────────────────────
@@ -267,6 +342,18 @@ export interface ToolbarProps {
theme?: RichTextTheme;
/** Whether to show the toolbar. */
visible?: boolean;
+ /** Currently selected serialized output format. */
+ outputFormat?: OutputFormat;
+ /** Currently selected preview mode. */
+ outputPreviewMode?: OutputPreviewMode;
+ /** Called when the output format changes from the toolbar. */
+ onOutputFormatChange?: (format: OutputFormat) => void;
+ /** Called when the preview mode changes from the toolbar. */
+ onOutputPreviewModeChange?: (mode: OutputPreviewMode) => void;
+ /** Called when the link button is pressed. */
+ onRequestLink?: () => void;
+ /** Called when the image button is pressed. */
+ onRequestImage?: () => void;
/** Custom render function for the entire toolbar. */
renderToolbar?: (props: ToolbarRenderProps) => React.ReactElement | null;
}
@@ -297,10 +384,26 @@ export interface RichTextInputProps {
theme?: RichTextTheme;
/** Whether to show the serialized output preview below the input. */
showOutputPreview?: boolean;
- /** Format used for the serialized output preview. */
+ /** Controlled format used for the serialized output preview. */
outputFormat?: OutputFormat;
+ /** Initial format used for the serialized output preview. */
+ defaultOutputFormat?: OutputFormat;
+ /** Controlled preview mode for the output panel. */
+ outputPreviewMode?: OutputPreviewMode;
+ /** Initial preview mode for the output panel. */
+ defaultOutputPreviewMode?: OutputPreviewMode;
+ /** Maximum height for the output preview panel. */
+ maxOutputHeight?: number;
/** Callback when the serialized output changes. */
onChangeOutput?: (output: string, format: OutputFormat) => void;
+ /** Callback when the output format changes. */
+ onChangeOutputFormat?: (format: OutputFormat) => void;
+ /** Callback when the output preview mode changes. */
+ onChangeOutputPreviewMode?: (mode: OutputPreviewMode) => void;
+ /** Invoked when the built-in link button needs a URL. */
+ onRequestLink?: (payload: LinkRequestPayload) => void;
+ /** Invoked when the built-in image button needs an image source. */
+ onRequestImage?: (payload: ImageRequestPayload) => void;
/** Whether multiline input is enabled. */
multiline?: boolean;
/** Minimum height for the input area. */
diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts
index 018459a..d80d11a 100644
--- a/src/utils/formatter.ts
+++ b/src/utils/formatter.ts
@@ -3,7 +3,9 @@ import type {
FormatType,
FormatStyle,
HeadingLevel,
+ ListType,
SelectionRange,
+ TextAlign,
} from '../types';
import {
createSegment,
@@ -67,14 +69,37 @@ export function setHeadingOnLine(
selection: SelectionRange,
level: HeadingLevel,
): StyledSegment[] {
- const plainText = segmentsToPlainText(segments);
- const { lineStart, lineEnd } = getLineRange(plainText, selection.start);
-
- const headingStyle: Partial = {
+ return setLineStyleOnSelection(segments, selection, {
heading: level === 'none' ? undefined : level,
- };
+ listType: level === 'none' ? undefined : undefined,
+ });
+}
- return applyStyleToRange(segments, lineStart, lineEnd, headingStyle);
+/**
+ * Apply a list type to the lines containing the cursor/selection.
+ */
+export function setListTypeOnLine(
+ segments: StyledSegment[],
+ selection: SelectionRange,
+ listType: ListType,
+): StyledSegment[] {
+ return setLineStyleOnSelection(segments, selection, {
+ listType: listType === 'none' ? undefined : listType,
+ heading: listType === 'none' ? undefined : undefined,
+ });
+}
+
+/**
+ * Apply text alignment to the lines containing the cursor/selection.
+ */
+export function setTextAlignOnLine(
+ segments: StyledSegment[],
+ selection: SelectionRange,
+ textAlign: TextAlign,
+): StyledSegment[] {
+ return setLineStyleOnSelection(segments, selection, {
+ textAlign,
+ });
}
/**
@@ -138,6 +163,11 @@ export function getSelectionStyle(
result.backgroundColor = undefined;
if (result.fontSize !== s.fontSize) result.fontSize = undefined;
if (result.heading !== s.heading) result.heading = undefined;
+ if (result.listType !== s.listType) result.listType = undefined;
+ if (result.textAlign !== s.textAlign) result.textAlign = undefined;
+ if (result.link !== s.link) result.link = undefined;
+ if (result.imageSrc !== s.imageSrc) result.imageSrc = undefined;
+ if (result.imageAlt !== s.imageAlt) result.imageAlt = undefined;
}
return result;
@@ -248,6 +278,17 @@ function applyStyleToRange(
return mergeAdjacentSegments(result);
}
+function setLineStyleOnSelection(
+ segments: StyledSegment[],
+ selection: SelectionRange,
+ styleDelta: Partial,
+): StyledSegment[] {
+ const plainText = segmentsToPlainText(segments);
+ const { lineStart, lineEnd } = getSelectionLineRange(plainText, selection);
+
+ return applyStyleToRange(segments, lineStart, lineEnd, styleDelta);
+}
+
/**
* Get the line start and end positions for the line containing the given position.
*/
@@ -268,5 +309,18 @@ function getLineRange(
return { lineStart, lineEnd };
}
+function getSelectionLineRange(
+ text: string,
+ selection: SelectionRange,
+): { lineStart: number; lineEnd: number } {
+ const normalized = normalizeSelection(selection);
+ const lineStart = getLineRange(text, normalized.start).lineStart;
+ const effectiveEnd =
+ normalized.end > normalized.start ? normalized.end - 1 : normalized.end;
+ const lineEnd = getLineRange(text, effectiveEnd).lineEnd;
+
+ return { lineStart, lineEnd };
+}
+
// Re-export for convenience
export { createSegment } from '../utils/parser';
diff --git a/src/utils/parser.ts b/src/utils/parser.ts
index c38fe84..bb63a5a 100644
--- a/src/utils/parser.ts
+++ b/src/utils/parser.ts
@@ -77,7 +77,12 @@ export function areStylesEqual(a: FormatStyle, b: FormatStyle): boolean {
(a.color ?? undefined) === (b.color ?? undefined) &&
(a.backgroundColor ?? undefined) === (b.backgroundColor ?? undefined) &&
(a.fontSize ?? undefined) === (b.fontSize ?? undefined) &&
- (a.heading ?? undefined) === (b.heading ?? undefined)
+ (a.heading ?? undefined) === (b.heading ?? undefined) &&
+ (a.listType ?? undefined) === (b.listType ?? undefined) &&
+ (a.textAlign ?? undefined) === (b.textAlign ?? undefined) &&
+ (a.link ?? undefined) === (b.link ?? undefined) &&
+ (a.imageSrc ?? undefined) === (b.imageSrc ?? undefined) &&
+ (a.imageAlt ?? undefined) === (b.imageAlt ?? undefined)
);
}
diff --git a/src/utils/serializer.ts b/src/utils/serializer.ts
index c2cc139..4413b3d 100644
--- a/src/utils/serializer.ts
+++ b/src/utils/serializer.ts
@@ -1,8 +1,10 @@
import type {
FormatStyle,
HeadingLevel,
+ ListType,
OutputFormat,
StyledSegment,
+ TextAlign,
} from '../types';
type LineFragment = Pick;
@@ -15,7 +17,29 @@ export function serializeSegments(
format: OutputFormat = 'markdown',
): string {
const lines = splitSegmentsByLine(segments);
- return lines.map((line) => serializeLine(line, format)).join('\n');
+ const blocks: string[] = [];
+
+ for (let index = 0; index < lines.length; ) {
+ const line = lines[index];
+ const listType = getLineListType(line);
+
+ if (listType && listType !== 'none') {
+ const listLines: LineFragment[][] = [];
+
+ while (index < lines.length && getLineListType(lines[index]) === listType) {
+ listLines.push(lines[index]);
+ index++;
+ }
+
+ blocks.push(serializeListBlock(listLines, format, listType));
+ continue;
+ }
+
+ blocks.push(serializeBlockLine(line, format));
+ index++;
+ }
+
+ return blocks.join('\n');
}
/**
@@ -39,7 +63,7 @@ function splitSegmentsByLine(segments: StyledSegment[]): LineFragment[][] {
const parts = segment.text.split('\n');
parts.forEach((part, index) => {
- if (part.length > 0) {
+ if (part.length > 0 || segment.styles.imageSrc) {
lines[lines.length - 1]?.push({
text: part,
styles: { ...segment.styles },
@@ -55,18 +79,48 @@ function splitSegmentsByLine(segments: StyledSegment[]): LineFragment[][] {
return lines;
}
-function serializeLine(
+function serializeListBlock(
+ lines: LineFragment[][],
+ format: OutputFormat,
+ listType: ListType,
+): string {
+ if (format === 'html' || lines.some((line) => !!getLineTextAlign(line))) {
+ const tag = listType === 'ordered' ? 'ol' : 'ul';
+ const items = lines.map((line) => serializeHtmlListItem(line)).join('');
+ return `<${tag}>${items}${tag}>`;
+ }
+
+ return lines
+ .map((line, index) => {
+ const marker = listType === 'ordered' ? `${index + 1}.` : '-';
+ const content = serializeLineContent(line, format);
+ return content.length > 0 ? `${marker} ${content}` : marker;
+ })
+ .join('\n');
+}
+
+function serializeHtmlListItem(line: LineFragment[]): string {
+ const content = serializeLineContent(line, 'html');
+ const styleAttribute = buildBlockStyle(getLineTextAlign(line));
+ return `${content}`;
+}
+
+function serializeBlockLine(
line: LineFragment[],
format: OutputFormat,
): string {
const heading = getLineHeading(line);
- const content = line
- .map((fragment) => serializeFragment(fragment, format, heading))
- .join('');
+ const textAlign = getLineTextAlign(line);
+ const content = serializeLineContent(line, format, heading);
if (format === 'html') {
const blockTag = heading ?? 'p';
- return `<${blockTag}>${content}${blockTag}>`;
+ const styleAttribute = buildBlockStyle(textAlign);
+ return `<${blockTag}${styleAttribute ? ` style="${styleAttribute}"` : ''}>${content}${blockTag}>`;
+ }
+
+ if (textAlign) {
+ return serializeAlignedMarkdownLine(content, heading, textAlign);
}
const headingPrefix = getHeadingPrefix(heading);
@@ -77,15 +131,42 @@ function serializeLine(
return content.length > 0 ? `${headingPrefix} ${content}` : headingPrefix;
}
+function serializeAlignedMarkdownLine(
+ content: string,
+ heading: HeadingLevel | undefined,
+ textAlign: TextAlign,
+): string {
+ const blockTag = heading ?? 'p';
+ const styleAttribute = buildBlockStyle(textAlign);
+ return `<${blockTag} style="${styleAttribute}">${content}${blockTag}>`;
+}
+
+function serializeLineContent(
+ line: LineFragment[],
+ format: OutputFormat,
+ lineHeading?: HeadingLevel,
+): string {
+ return line
+ .map((fragment) => serializeFragment(fragment, format, lineHeading))
+ .join('');
+}
+
function serializeFragment(
fragment: LineFragment,
format: OutputFormat,
lineHeading?: HeadingLevel,
): string {
+ if (fragment.styles.imageSrc) {
+ return serializeImageFragment(fragment, format);
+ }
+
const normalizedStyles: FormatStyle = {
...fragment.styles,
heading: undefined,
- // Markdown headings already express emphasis at the block level.
+ listType: undefined,
+ textAlign: undefined,
+ imageSrc: undefined,
+ imageAlt: undefined,
bold:
lineHeading && lineHeading !== 'none' ? false : fragment.styles.bold,
};
@@ -95,6 +176,20 @@ function serializeFragment(
: serializeMarkdownFragment(fragment.text, normalizedStyles);
}
+function serializeImageFragment(
+ fragment: LineFragment,
+ format: OutputFormat,
+): string {
+ const source = fragment.styles.imageSrc ?? '';
+ const altText = fragment.styles.imageAlt ?? extractImageAlt(fragment.text);
+
+ if (format === 'html') {
+ return `
`;
+ }
+
+ return `})`;
+}
+
function serializeHtmlFragment(text: string, styles: FormatStyle): string {
let result = escapeHtml(text);
@@ -123,6 +218,10 @@ function serializeHtmlFragment(text: string, styles: FormatStyle): string {
result = `${result}`;
}
+ if (styles.link) {
+ result = `${result}`;
+ }
+
return result;
}
@@ -154,6 +253,10 @@ function serializeMarkdownFragment(text: string, styles: FormatStyle): string {
result = `${result}`;
}
+ if (styles.link) {
+ result = `[${result}](${escapeMarkdownUrl(styles.link)})`;
+ }
+
return result;
}
@@ -175,6 +278,16 @@ function buildInlineStyle(styles: FormatStyle): string {
return cssRules.join('; ');
}
+function buildBlockStyle(textAlign?: TextAlign): string {
+ const cssRules: string[] = [];
+
+ if (textAlign) {
+ cssRules.push(`text-align: ${textAlign}`);
+ }
+
+ return cssRules.join('; ');
+}
+
function getLineHeading(line: LineFragment[]): HeadingLevel | undefined {
for (const fragment of line) {
if (fragment.styles.heading && fragment.styles.heading !== 'none') {
@@ -185,6 +298,26 @@ function getLineHeading(line: LineFragment[]): HeadingLevel | undefined {
return undefined;
}
+function getLineListType(line: LineFragment[]): ListType | undefined {
+ for (const fragment of line) {
+ if (fragment.styles.listType && fragment.styles.listType !== 'none') {
+ return fragment.styles.listType;
+ }
+ }
+
+ return undefined;
+}
+
+function getLineTextAlign(line: LineFragment[]): TextAlign | undefined {
+ for (const fragment of line) {
+ if (fragment.styles.textAlign) {
+ return fragment.styles.textAlign;
+ }
+ }
+
+ return undefined;
+}
+
function getHeadingPrefix(heading?: HeadingLevel): string | undefined {
switch (heading) {
case 'h1':
@@ -198,6 +331,11 @@ function getHeadingPrefix(heading?: HeadingLevel): string | undefined {
}
}
+function extractImageAlt(text: string): string {
+ const normalized = text.replace(/^\[Image:\s*/i, '').replace(/^\[Image\]/i, '').replace(/\]$/, '').trim();
+ return normalized.length > 0 ? normalized : 'image';
+}
+
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&')
@@ -211,6 +349,10 @@ function escapeMarkdown(text: string): string {
return text.replace(/([\\`*_~[\]])/g, '\\$1');
}
+function escapeMarkdownUrl(url: string): string {
+ return url.replaceAll(' ', '%20').replaceAll(')', '%29');
+}
+
function wrapInlineCode(text: string): string {
const matches = text.match(/`+/g);
const longestBacktickRun = matches?.reduce(
diff --git a/src/utils/styleMapper.ts b/src/utils/styleMapper.ts
index f7fe23f..668f22a 100644
--- a/src/utils/styleMapper.ts
+++ b/src/utils/styleMapper.ts
@@ -55,6 +55,10 @@ export function formatStyleToTextStyle(
style.fontSize = formatStyle.fontSize;
}
+ if (formatStyle.textAlign) {
+ style.textAlign = formatStyle.textAlign;
+ }
+
// Heading — overrides font size and weight
if (formatStyle.heading && formatStyle.heading !== 'none') {
style.fontSize = HEADING_FONT_SIZES[formatStyle.heading];
@@ -62,6 +66,23 @@ export function formatStyleToTextStyle(
style.lineHeight = HEADING_FONT_SIZES[formatStyle.heading] * 1.3;
}
+ if (formatStyle.link) {
+ style.color =
+ style.color ??
+ resolvedTheme.colors?.link ??
+ DEFAULT_THEME.colors?.link ??
+ DEFAULT_THEME.colors?.primary;
+
+ if (style.textDecorationLine === 'line-through') {
+ style.textDecorationLine = 'underline line-through';
+ } else if (
+ !style.textDecorationLine ||
+ style.textDecorationLine === 'none'
+ ) {
+ style.textDecorationLine = 'underline';
+ }
+ }
+
return style;
}