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( + '![Photo](https://cdn.test/photo.png)', + ); + }); + }); + // ─── 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

\n

Paragraph

'); }); + + it('serializes list items as grouped markdown lists', () => { + const output = serializeSegments( + [ + createSegment('One', { listType: 'bullet' }), + createSegment('\n'), + createSegment('Two', { listType: 'bullet' }), + ], + 'markdown', + ); + + expect(output).toBe('- One\n- Two'); + }); + + it('serializes links and images', () => { + const output = serializeSegments( + [ + createSegment('Docs', { link: 'https://openai.com' }), + createSegment('\n'), + createSegment('[Image: Hero]', { + imageSrc: 'https://cdn.test/hero.png', + imageAlt: 'Hero', + }), + ], + 'markdown', + ); + + expect(output).toBe( + '[Docs](https://openai.com)\n![Hero](https://cdn.test/hero.png)', + ); + }); + + it('uses html block fallback for markdown alignment', () => { + const output = serializeSegments( + [createSegment('Centered', { textAlign: 'center' })], + 'markdown', + ); + + expect(output).toBe('

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}`; + } + + 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}`; + const styleAttribute = buildBlockStyle(textAlign); + return `<${blockTag}${styleAttribute ? ` style="${styleAttribute}"` : ''}>${content}`; + } + + 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}`; +} + +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 `${escapeHtml(altText)}`; + } + + return `![${escapeMarkdown(altText)}](${escapeMarkdownUrl(source)})`; +} + 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; }