From fc56f79dc43380c2bf988e64372eedbd1728f564 Mon Sep 17 00:00:00 2001 From: soumya-99 Date: Sun, 5 Apr 2026 14:38:11 +0530 Subject: [PATCH] feat: new structure created for testing --- __tests__/components/RichTextInput.test.tsx | 39 +++- __tests__/components/Toolbar.test.tsx | 1 + __tests__/hooks/useFormatting.test.ts | 12 ++ __tests__/hooks/useRichText.test.ts | 56 +++++ __tests__/utils/formatter.test.ts | 9 +- __tests__/utils/serializer.test.ts | 66 ++++++ package.json | 2 +- src/components/RichTextInput.d.ts | 17 +- src/components/RichTextInput.tsx | 157 +++++++++----- src/constants/defaultStyles.d.ts | 4 +- src/constants/defaultStyles.ts | 27 ++- src/hooks/useFormatting.ts | 15 +- src/hooks/useRichText.ts | 98 ++++++++- src/index.d.ts | 3 +- src/index.ts | 6 + src/types/index.d.ts | 20 +- src/types/index.ts | 21 +- src/utils/formatter.ts | 6 +- src/utils/serializer.d.ts | 13 ++ src/utils/serializer.ts | 223 ++++++++++++++++++++ 20 files changed, 704 insertions(+), 91 deletions(-) create mode 100644 __tests__/utils/serializer.test.ts create mode 100644 src/utils/serializer.d.ts create mode 100644 src/utils/serializer.ts diff --git a/__tests__/components/RichTextInput.test.tsx b/__tests__/components/RichTextInput.test.tsx index 221f279..f95a91f 100644 --- a/__tests__/components/RichTextInput.test.tsx +++ b/__tests__/components/RichTextInput.test.tsx @@ -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(); }); @@ -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( { expect(input.props.style).toEqual( expect.arrayContaining([ expect.objectContaining({ letterSpacing: 2 }), - expect.objectContaining({ color: 'transparent' }), + expect.objectContaining({ color: expect.any(String) }), ]), ); }); @@ -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( + , + ); + + fireEvent.changeText(getByPlaceholderText('Type...'), 'Hello'); + + expect(getByText('Markdown output')).toBeTruthy(); + expect(getByText('Hello')).toBeTruthy(); + }); + + it('renders HTML output when requested', () => { + const { getByText } = render( + , + ); + + expect(getByText('HTML output')).toBeTruthy(); + expect(getByText('

Title

')).toBeTruthy(); + }); + + it('hides the output panel when showOutputPreview is false', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Markdown output')).toBeNull(); + }); + it('renders with minHeight', () => { const { toJSON } = render( , diff --git a/__tests__/components/Toolbar.test.tsx b/__tests__/components/Toolbar.test.tsx index 78f5084..c4e9e74 100644 --- a/__tests__/components/Toolbar.test.tsx +++ b/__tests__/components/Toolbar.test.tsx @@ -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(), diff --git a/__tests__/hooks/useFormatting.test.ts b/__tests__/hooks/useFormatting.test.ts index e87b1a9..172fa22 100644 --- a/__tests__/hooks/useFormatting.test.ts +++ b/__tests__/hooks/useFormatting.test.ts @@ -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', () => { diff --git a/__tests__/hooks/useRichText.test.ts b/__tests__/hooks/useRichText.test.ts index 5a3e072..5c64dba 100644 --- a/__tests__/hooks/useRichText.test.ts +++ b/__tests__/hooks/useRichText.test.ts @@ -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({ @@ -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('

Title

'); + }); }); // ─── Selection Changes ────────────────────────────────────────────────── @@ -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 ───────────────────────────────────────────────────── diff --git a/__tests__/utils/formatter.test.ts b/__tests__/utils/formatter.test.ts index 9820344..e8243ca 100644 --- a/__tests__/utils/formatter.test.ts +++ b/__tests__/utils/formatter.test.ts @@ -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', () => { @@ -187,7 +185,6 @@ describe('formatter', () => { ); expect(result[0].styles.heading).toBe('h2'); - expect(result[0].styles.fontSize).toBe(24); }); it('applies h3 heading', () => { @@ -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', () => { diff --git a/__tests__/utils/serializer.test.ts b/__tests__/utils/serializer.test.ts new file mode 100644 index 0000000..a5fb769 --- /dev/null +++ b/__tests__/utils/serializer.test.ts @@ -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( + '

Title!

', + ); + }); + + it('uses inline HTML for markdown-only unsupported styles', () => { + const output = serializeSegments( + [ + createSegment('Marked', { + underline: true, + color: '#00aa00', + }), + ], + 'markdown', + ); + + expect(output).toBe( + 'Marked', + ); + }); + + it('preserves line breaks between serialized blocks', () => { + const output = serializeSegments( + [ + createSegment('Title', { heading: 'h3' }), + createSegment('\n'), + createSegment('Paragraph'), + ], + 'html', + ); + + expect(output).toBe('

Title

\n

Paragraph

'); + }); +}); diff --git a/package.json b/package.json index bed429e..78b0450 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/RichTextInput.d.ts b/src/components/RichTextInput.d.ts index 404d057..8611d2f 100644 --- a/src/components/RichTextInput.d.ts +++ b/src/components/RichTextInput.d.ts @@ -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 `` layer behind it renders the formatted content - * - Both share identical font metrics for pixel-perfect alignment - * - * @example - * ```tsx - * 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; diff --git a/src/components/RichTextInput.tsx b/src/components/RichTextInput.tsx index e5c1c24..341c607 100644 --- a/src/components/RichTextInput.tsx +++ b/src/components/RichTextInput.tsx @@ -1,34 +1,35 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useMemo, useRef } from 'react'; import { - View, - TextInput, + Animated, + Easing, + ScrollView, StyleSheet, + Text, + TextInput, + View, type NativeSyntheticEvent, type TextInputSelectionChangeEventData, } from 'react-native'; import type { RichTextInputProps } from '../types'; import { DEFAULT_THEME } from '../constants/defaultStyles'; -import { segmentsToPlainText } from '../utils/parser'; import { useRichText } from '../hooks/useRichText'; -import { OverlayText } from './OverlayText'; +import { segmentsToPlainText } from '../utils/parser'; +import { serializeSegments } from '../utils/serializer'; import { Toolbar } from './Toolbar'; +const OUTPUT_PANEL_HEIGHT = 180; +const isJestRuntime = + typeof ( + globalThis as { + process?: { env?: { JEST_WORKER_ID?: string } }; + } + ).process?.env?.JEST_WORKER_ID === 'string'; + /** * RichTextInput — The main rich text editor component. * - * Uses the Overlay Technique: - * - A transparent `TextInput` on top captures user input and selection - * - A styled `` layer behind it renders the formatted content - * - Both share identical font metrics for pixel-perfect alignment - * - * @example - * ```tsx - * console.log(segments)} - * /> - * ``` + * Uses a plain `TextInput` for editing and renders the serialized rich output + * below it as Markdown or HTML. */ export const RichTextInput: React.FC = ({ initialSegments, @@ -41,6 +42,9 @@ export const RichTextInput: React.FC = ({ toolbarPosition = 'top', toolbarItems, theme, + showOutputPreview = true, + outputFormat = 'markdown', + onChangeOutput, multiline = true, minHeight = 120, maxHeight, @@ -50,6 +54,7 @@ export const RichTextInput: React.FC = ({ onReady, }) => { const resolvedTheme = theme ?? DEFAULT_THEME; + const previewProgress = useRef(new Animated.Value(0)).current; const { state, actions } = useRichText({ initialSegments, @@ -57,15 +62,35 @@ export const RichTextInput: React.FC = ({ onChangeText, }); - // Expose actions via onReady callback useEffect(() => { onReady?.(actions); - }, [onReady, actions]); + }, [actions, onReady]); - // Build plain text for the TextInput value const plainText = segmentsToPlainText(state.segments); + const serializedOutput = useMemo( + () => serializeSegments(state.segments, outputFormat), + [outputFormat, state.segments], + ); + const shouldShowOutputPreview = showOutputPreview && plainText.length > 0; + + useEffect(() => { + onChangeOutput?.(serializedOutput, outputFormat); + }, [onChangeOutput, outputFormat, serializedOutput]); + + useEffect(() => { + if (isJestRuntime) { + previewProgress.setValue(shouldShowOutputPreview ? 1 : 0); + return; + } + + Animated.timing(previewProgress, { + toValue: shouldShowOutputPreview ? 1 : 0, + duration: 180, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }, [previewProgress, shouldShowOutputPreview]); - // Handle selection change from TextInput const onSelectionChange = useCallback( (e: NativeSyntheticEvent) => { const { start, end } = e.nativeEvent.selection; @@ -74,28 +99,49 @@ export const RichTextInput: React.FC = ({ [actions], ); - // Container style const containerStyle = [ resolvedTheme.containerStyle ?? DEFAULT_THEME.containerStyle, ]; - - // Input area style const inputAreaStyle = [ styles.inputArea, { minHeight }, maxHeight ? { maxHeight } : undefined, ]; - - // Input style const inputStyle = [ styles.textInput, resolvedTheme.baseTextStyle ?? DEFAULT_THEME.baseTextStyle, resolvedTheme.inputStyle ?? DEFAULT_THEME.inputStyle, textInputProps?.style, - styles.hiddenInputText, + ]; + const outputAnimatedStyle = { + maxHeight: previewProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0, OUTPUT_PANEL_HEIGHT], + }), + opacity: previewProgress, + marginTop: previewProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0, 12], + }), + transform: [ + { + translateY: previewProgress.interpolate({ + inputRange: [0, 1], + outputRange: [-8, 0], + }), + }, + ], + }; + const outputContainerStyle = [ + resolvedTheme.outputContainerStyle ?? DEFAULT_THEME.outputContainerStyle, + ]; + const outputLabelStyle = [ + resolvedTheme.outputLabelStyle ?? DEFAULT_THEME.outputLabelStyle, + ]; + const outputTextStyle = [ + resolvedTheme.outputTextStyle ?? DEFAULT_THEME.outputTextStyle, ]; - // Toolbar component const toolbarComponent = showToolbar ? ( = ({ /> ) : null; - // Toolbar border const toolbarBorderStyle = toolbarPosition === 'top' - ? { borderBottomWidth: 1, borderBottomColor: resolvedTheme.colors?.toolbarBorder ?? DEFAULT_THEME.colors?.toolbarBorder } - : { borderTopWidth: 1, borderTopColor: resolvedTheme.colors?.toolbarBorder ?? DEFAULT_THEME.colors?.toolbarBorder }; + ? { + borderBottomWidth: 1, + borderBottomColor: + resolvedTheme.colors?.toolbarBorder ?? + DEFAULT_THEME.colors?.toolbarBorder, + } + : { + borderTopWidth: 1, + borderTopColor: + resolvedTheme.colors?.toolbarBorder ?? + DEFAULT_THEME.colors?.toolbarBorder, + }; return ( - {/* Toolbar — Top */} {toolbarPosition === 'top' && toolbarComponent && ( {toolbarComponent} )} - {/* Editor Area */} - {/* Overlay — Styled text rendering (behind TextInput) */} - - - {/* TextInput — Transparent layer on top for input capture */} = ({ editable={editable} maxLength={maxLength} autoFocus={autoFocus} - underlineColorAndroid="transparent" selectionColor={ resolvedTheme.colors?.cursor ?? DEFAULT_THEME.colors?.cursor } textAlignVertical="top" scrollEnabled={typeof maxHeight === 'number'} /> + + {showOutputPreview && ( + + + + {outputFormat === 'html' ? 'HTML output' : 'Markdown output'} + + + + {serializedOutput} + + + + + )} - {/* Toolbar — Bottom */} {toolbarPosition === 'bottom' && toolbarComponent && ( {toolbarComponent} )} @@ -169,12 +230,8 @@ const styles = StyleSheet.create({ }, textInput: { position: 'relative', - zIndex: 1, }, - hiddenInputText: { - // Keep the editable layer invisible while preserving the caret. - color: 'transparent', - backgroundColor: 'transparent', - textShadowColor: 'transparent', + outputAnimatedWrapper: { + overflow: 'hidden', }, }); diff --git a/src/constants/defaultStyles.d.ts b/src/constants/defaultStyles.d.ts index c4be966..045fe60 100644 --- a/src/constants/defaultStyles.d.ts +++ b/src/constants/defaultStyles.d.ts @@ -9,6 +9,8 @@ export declare const DEFAULT_COLORS: { readonly placeholder: "#9CA3AF"; readonly toolbarBackground: "#F9FAFB"; readonly toolbarBorder: "#E5E7EB"; + readonly outputBackground: "#F8FAFC"; + readonly outputLabel: "#475569"; readonly cursor: "#6366F1"; readonly activeButtonBg: "#EEF2FF"; readonly codeBackground: "#F3F4F6"; @@ -32,7 +34,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 12b356e..4c240a8 100644 --- a/src/constants/defaultStyles.ts +++ b/src/constants/defaultStyles.ts @@ -10,6 +10,8 @@ export const DEFAULT_COLORS = { placeholder: '#9CA3AF', toolbarBackground: '#F9FAFB', toolbarBorder: '#E5E7EB', + outputBackground: '#F8FAFC', + outputLabel: '#475569', cursor: '#6366F1', activeButtonBg: '#EEF2FF', codeBackground: '#F3F4F6', @@ -64,7 +66,7 @@ export const DEFAULT_THEME: RichTextTheme = { inputStyle: { fontSize: DEFAULT_BASE_TEXT_STYLE.fontSize, lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight, - color: 'transparent', + color: DEFAULT_COLORS.text, paddingHorizontal: 16, paddingVertical: 12, textAlignVertical: 'top', @@ -83,6 +85,29 @@ export const DEFAULT_THEME: RichTextTheme = { lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight, color: DEFAULT_COLORS.text, }, + outputContainerStyle: { + marginHorizontal: 12, + marginBottom: 12, + padding: 12, + borderRadius: 10, + borderWidth: 1, + borderColor: DEFAULT_COLORS.toolbarBorder, + backgroundColor: DEFAULT_COLORS.outputBackground, + }, + outputLabelStyle: { + marginBottom: 8, + fontSize: 12, + fontWeight: '700', + letterSpacing: 0.4, + color: DEFAULT_COLORS.outputLabel, + textTransform: 'uppercase', + }, + outputTextStyle: { + fontSize: 14, + lineHeight: 20, + color: DEFAULT_COLORS.text, + fontFamily: 'monospace', + }, toolbarStyle: { flexDirection: 'row', alignItems: 'center', diff --git a/src/hooks/useFormatting.ts b/src/hooks/useFormatting.ts index 925e4f1..17dbbc2 100644 --- a/src/hooks/useFormatting.ts +++ b/src/hooks/useFormatting.ts @@ -78,10 +78,23 @@ export function useFormatting({ const setHeading = useCallback( (level: HeadingLevel) => { + if (selection.start === selection.end) { + onActiveStylesChange({ + ...activeStyles, + heading: level === 'none' ? undefined : level, + }); + } + const newSegments = setHeadingOnLine(segments, selection, level); onSegmentsChange(newSegments); }, - [segments, selection, onSegmentsChange], + [ + activeStyles, + onActiveStylesChange, + onSegmentsChange, + segments, + selection, + ], ); const setColor = useCallback( diff --git a/src/hooks/useRichText.ts b/src/hooks/useRichText.ts index e0b0a5e..177f772 100644 --- a/src/hooks/useRichText.ts +++ b/src/hooks/useRichText.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useRef } from 'react'; import type { StyledSegment, FormatStyle, + OutputFormat, SelectionRange, RichTextState, RichTextActions, @@ -15,6 +16,7 @@ import { findPositionInSegments, } from '../utils/parser'; import { getSelectionStyle } from '../utils/formatter'; +import { serializeSegments } from '../utils/serializer'; import { useSelection } from '../hooks/useSelection'; import { useFormatting } from '../hooks/useFormatting'; @@ -60,6 +62,7 @@ export function useRichText( selectionRef.current = selection; const activeStylesRef = useRef(activeStyles); activeStylesRef.current = activeStyles; + const preserveActiveStylesRef = useRef(false); // ─── Segment Change Handler ────────────────────────────────────────────── @@ -108,8 +111,22 @@ export function useRichText( const onSelectionChange = useCallback( (newSelection: SelectionRange) => { + const previousSelection = selectionRef.current; handleSelectionChange(newSelection); + const shouldPreserveActiveStyles = + preserveActiveStylesRef.current && + previousSelection.start === previousSelection.end && + newSelection.start === newSelection.end && + newSelection.start >= previousSelection.start && + newSelection.start - previousSelection.start <= 1; + + if (shouldPreserveActiveStyles) { + return; + } + + preserveActiveStylesRef.current = false; + // Update active styles based on cursor position if (newSelection.start === newSelection.end) { const pos = findPositionInSegments( @@ -131,6 +148,13 @@ export function useRichText( return segmentsToPlainText(segmentsRef.current); }, []); + const getOutput = useCallback( + (format: OutputFormat = 'markdown'): string => { + return serializeSegments(segmentsRef.current, format); + }, + [], + ); + const exportJSON = useCallback((): StyledSegment[] => { return JSON.parse(JSON.stringify(segmentsRef.current)); }, []); @@ -147,8 +171,69 @@ export function useRichText( const clear = useCallback(() => { updateSegments([createSegment('')]); setActiveStyles({ ...EMPTY_FORMAT_STYLE }); + preserveActiveStylesRef.current = false; }, [updateSegments]); + const toggleFormat = useCallback( + (format) => { + if (selectionRef.current.start === selectionRef.current.end) { + preserveActiveStylesRef.current = true; + } + formatting.toggleFormat(format); + }, + [formatting], + ); + + const setStyleProperty = useCallback( + (key, value) => { + if (selectionRef.current.start === selectionRef.current.end) { + preserveActiveStylesRef.current = true; + } + formatting.setStyleProperty(key, value); + }, + [formatting], + ); + + const setHeading = useCallback( + (level) => { + if (selectionRef.current.start === selectionRef.current.end) { + preserveActiveStylesRef.current = true; + } + formatting.setHeading(level); + }, + [formatting], + ); + + const setColor = useCallback( + (color) => { + if (selectionRef.current.start === selectionRef.current.end) { + preserveActiveStylesRef.current = true; + } + formatting.setColor(color); + }, + [formatting], + ); + + const setBackgroundColor = useCallback( + (color) => { + if (selectionRef.current.start === selectionRef.current.end) { + preserveActiveStylesRef.current = true; + } + formatting.setBackgroundColor(color); + }, + [formatting], + ); + + const setFontSize = useCallback( + (size) => { + if (selectionRef.current.start === selectionRef.current.end) { + preserveActiveStylesRef.current = true; + } + formatting.setFontSize(size); + }, + [formatting], + ); + // ─── Build Return Value ────────────────────────────────────────────────── const state: RichTextState = { @@ -158,16 +243,17 @@ export function useRichText( }; const actions: RichTextActions = { - toggleFormat: formatting.toggleFormat, - setStyleProperty: formatting.setStyleProperty, - setHeading: formatting.setHeading, - setColor: formatting.setColor, - setBackgroundColor: formatting.setBackgroundColor, - setFontSize: formatting.setFontSize, + toggleFormat, + setStyleProperty, + setHeading, + setColor, + setBackgroundColor, + setFontSize, handleTextChange, handleSelectionChange: onSelectionChange, isFormatActive: formatting.isFormatActive, getSelectionStyle: formatting.currentSelectionStyle, + getOutput, getPlainText, exportJSON, importJSON, diff --git a/src/index.d.ts b/src/index.d.ts index 2421596..2e29b32 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -11,5 +11,6 @@ export type { RichTextProviderProps } from './context/RichTextContext'; export { createSegment, segmentsToPlainText, getTotalLength, mergeAdjacentSegments, reconcileTextChange, } from './utils/parser'; export { toggleFormatOnSelection, setStyleOnSelection, setHeadingOnLine, isFormatActiveInSelection, getSelectionStyle, } from './utils/formatter'; 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, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, ToolbarButtonRenderProps, ToolbarRenderProps, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types'; +export type { FormatType, HeadingLevel, ListType, OutputFormat, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, ToolbarButtonRenderProps, ToolbarRenderProps, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types'; diff --git a/src/index.ts b/src/index.ts index 2e5bdae..e3c489f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,11 @@ export { segmentToTextStyle, segmentsToTextStyles, } from './utils/styleMapper'; +export { + serializeSegments, + segmentsToMarkdown, + segmentsToHTML, +} from './utils/serializer'; // ─── Constants ─────────────────────────────────────────────────────────────── export { @@ -53,6 +58,7 @@ export type { FormatType, HeadingLevel, ListType, + OutputFormat, FormatStyle, StyledSegment, SelectionRange, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index cfdcfb6..c8c6ce1 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -11,6 +11,10 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none'; * List type for a line/paragraph. */ export type ListType = 'bullet' | 'ordered' | 'none'; +/** + * Serialized output formats supported by the editor. + */ +export type OutputFormat = 'markdown' | 'html'; /** * Inline formatting styles attached to a text segment. */ @@ -79,6 +83,8 @@ export interface RichTextActions { isFormatActive: (format: FormatType) => boolean; /** Get the effective shared style at the current cursor/selection. */ getSelectionStyle: () => FormatStyle; + /** Serialize the current content as markdown or HTML. */ + getOutput: (format?: OutputFormat) => string; /** Get the full plain text content. */ getPlainText: () => string; /** Export the segments as a serializable JSON array. */ @@ -103,10 +109,16 @@ export interface RichTextTheme { containerStyle?: ViewStyle; /** Style for the TextInput. */ inputStyle?: TextStyle; - /** Style for the overlay text container. */ + /** Style for the legacy overlay text container. */ overlayContainerStyle?: ViewStyle; /** Base text style applied to all segments before formatting. */ baseTextStyle?: TextStyle; + /** Style for the serialized output container. */ + outputContainerStyle?: ViewStyle; + /** Label style for the serialized output header. */ + outputLabelStyle?: TextStyle; + /** Style for the serialized output text. */ + outputTextStyle?: TextStyle; /** Style for the toolbar container. */ toolbarStyle?: ViewStyle; /** Style for toolbar buttons. */ @@ -239,6 +251,12 @@ export interface RichTextInputProps { toolbarItems?: ToolbarItem[]; /** Theme configuration. */ theme?: RichTextTheme; + /** Whether to show the serialized output preview below the input. */ + showOutputPreview?: boolean; + /** Format used for the serialized output preview. */ + outputFormat?: OutputFormat; + /** Callback when the serialized output changes. */ + onChangeOutput?: (output: string, format: OutputFormat) => 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 ad4b549..4d98fb3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,11 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none'; */ export type ListType = 'bullet' | 'ordered' | 'none'; +/** + * Serialized output formats supported by the editor. + */ +export type OutputFormat = 'markdown' | 'html'; + // ─── Style Types ───────────────────────────────────────────────────────────── /** @@ -105,6 +110,8 @@ export interface RichTextActions { isFormatActive: (format: FormatType) => boolean; /** Get the effective shared style at the current cursor/selection. */ getSelectionStyle: () => FormatStyle; + /** Serialize the current content as markdown or HTML. */ + getOutput: (format?: OutputFormat) => string; /** Get the full plain text content. */ getPlainText: () => string; /** Export the segments as a serializable JSON array. */ @@ -135,10 +142,16 @@ export interface RichTextTheme { containerStyle?: ViewStyle; /** Style for the TextInput. */ inputStyle?: TextStyle; - /** Style for the overlay text container. */ + /** Style for the legacy overlay text container. */ overlayContainerStyle?: ViewStyle; /** Base text style applied to all segments before formatting. */ baseTextStyle?: TextStyle; + /** Style for the serialized output container. */ + outputContainerStyle?: ViewStyle; + /** Label style for the serialized output header. */ + outputLabelStyle?: TextStyle; + /** Style for the serialized output text. */ + outputTextStyle?: TextStyle; /** Style for the toolbar container. */ toolbarStyle?: ViewStyle; /** Style for toolbar buttons. */ @@ -282,6 +295,12 @@ export interface RichTextInputProps { toolbarItems?: ToolbarItem[]; /** Theme configuration. */ theme?: RichTextTheme; + /** Whether to show the serialized output preview below the input. */ + showOutputPreview?: boolean; + /** Format used for the serialized output preview. */ + outputFormat?: OutputFormat; + /** Callback when the serialized output changes. */ + onChangeOutput?: (output: string, format: OutputFormat) => 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 c81c768..018459a 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -8,11 +8,9 @@ import type { import { createSegment, findPositionInSegments, - splitSegment, mergeAdjacentSegments, segmentsToPlainText, } from '../utils/parser'; -import { HEADING_FONT_SIZES } from '../constants/defaultStyles'; /** * Toggle an inline format (bold, italic, etc.) on the selected range. @@ -73,9 +71,7 @@ export function setHeadingOnLine( const { lineStart, lineEnd } = getLineRange(plainText, selection.start); const headingStyle: Partial = { - heading: level, - fontSize: HEADING_FONT_SIZES[level], - bold: level !== 'none' ? true : undefined, + heading: level === 'none' ? undefined : level, }; return applyStyleToRange(segments, lineStart, lineEnd, headingStyle); diff --git a/src/utils/serializer.d.ts b/src/utils/serializer.d.ts new file mode 100644 index 0000000..6bf3f64 --- /dev/null +++ b/src/utils/serializer.d.ts @@ -0,0 +1,13 @@ +import type { OutputFormat, StyledSegment } from '../types'; +/** + * Serialize styled segments as Markdown or HTML. + */ +export declare function serializeSegments(segments: StyledSegment[], format?: OutputFormat): string; +/** + * Convenience wrapper for Markdown output. + */ +export declare function segmentsToMarkdown(segments: StyledSegment[]): string; +/** + * Convenience wrapper for HTML output. + */ +export declare function segmentsToHTML(segments: StyledSegment[]): string; diff --git a/src/utils/serializer.ts b/src/utils/serializer.ts new file mode 100644 index 0000000..c2cc139 --- /dev/null +++ b/src/utils/serializer.ts @@ -0,0 +1,223 @@ +import type { + FormatStyle, + HeadingLevel, + OutputFormat, + StyledSegment, +} from '../types'; + +type LineFragment = Pick; + +/** + * Serialize styled segments as Markdown or HTML. + */ +export function serializeSegments( + segments: StyledSegment[], + format: OutputFormat = 'markdown', +): string { + const lines = splitSegmentsByLine(segments); + return lines.map((line) => serializeLine(line, format)).join('\n'); +} + +/** + * Convenience wrapper for Markdown output. + */ +export function segmentsToMarkdown(segments: StyledSegment[]): string { + return serializeSegments(segments, 'markdown'); +} + +/** + * Convenience wrapper for HTML output. + */ +export function segmentsToHTML(segments: StyledSegment[]): string { + return serializeSegments(segments, 'html'); +} + +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) { + lines[lines.length - 1]?.push({ + text: part, + styles: { ...segment.styles }, + }); + } + + if (index < parts.length - 1) { + lines.push([]); + } + }); + } + + return lines; +} + +function serializeLine( + line: LineFragment[], + format: OutputFormat, +): string { + const heading = getLineHeading(line); + const content = line + .map((fragment) => serializeFragment(fragment, format, heading)) + .join(''); + + if (format === 'html') { + const blockTag = heading ?? 'p'; + return `<${blockTag}>${content}`; + } + + const headingPrefix = getHeadingPrefix(heading); + if (!headingPrefix) { + return content; + } + + return content.length > 0 ? `${headingPrefix} ${content}` : headingPrefix; +} + +function serializeFragment( + fragment: LineFragment, + format: OutputFormat, + lineHeading?: HeadingLevel, +): string { + const normalizedStyles: FormatStyle = { + ...fragment.styles, + heading: undefined, + // Markdown headings already express emphasis at the block level. + bold: + lineHeading && lineHeading !== 'none' ? false : fragment.styles.bold, + }; + + return format === 'html' + ? serializeHtmlFragment(fragment.text, normalizedStyles) + : serializeMarkdownFragment(fragment.text, normalizedStyles); +} + +function serializeHtmlFragment(text: string, styles: FormatStyle): string { + let result = escapeHtml(text); + + if (styles.code) { + result = `${result}`; + } + + if (styles.bold) { + result = `${result}`; + } + + if (styles.italic) { + result = `${result}`; + } + + if (styles.underline) { + result = `${result}`; + } + + if (styles.strikethrough) { + result = `${result}`; + } + + const styleAttribute = buildInlineStyle(styles); + if (styleAttribute) { + result = `${result}`; + } + + return result; +} + +function serializeMarkdownFragment(text: string, styles: FormatStyle): string { + let result = escapeMarkdown(text); + + if (styles.code) { + result = wrapInlineCode(text); + } + + if (styles.bold) { + result = `**${result}**`; + } + + if (styles.italic) { + result = `*${result}*`; + } + + if (styles.strikethrough) { + result = `~~${result}~~`; + } + + if (styles.underline) { + result = `${result}`; + } + + const styleAttribute = buildInlineStyle(styles); + if (styleAttribute) { + result = `${result}`; + } + + return result; +} + +function buildInlineStyle(styles: FormatStyle): string { + const cssRules: string[] = []; + + if (styles.color) { + cssRules.push(`color: ${styles.color}`); + } + + if (styles.backgroundColor) { + cssRules.push(`background-color: ${styles.backgroundColor}`); + } + + if (styles.fontSize) { + cssRules.push(`font-size: ${styles.fontSize}px`); + } + + return cssRules.join('; '); +} + +function getLineHeading(line: LineFragment[]): HeadingLevel | undefined { + for (const fragment of line) { + if (fragment.styles.heading && fragment.styles.heading !== 'none') { + return fragment.styles.heading; + } + } + + return undefined; +} + +function getHeadingPrefix(heading?: HeadingLevel): string | undefined { + switch (heading) { + case 'h1': + return '#'; + case 'h2': + return '##'; + case 'h3': + return '###'; + default: + return undefined; + } +} + +function escapeHtml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeMarkdown(text: string): string { + return text.replace(/([\\`*_~[\]])/g, '\\$1'); +} + +function wrapInlineCode(text: string): string { + const matches = text.match(/`+/g); + const longestBacktickRun = matches?.reduce( + (max, match) => Math.max(max, match.length), + 0, + ) ?? 0; + const fence = '`'.repeat(longestBacktickRun + 1); + + return `${fence}${text}${fence}`; +}