react-native-richify 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/lib/commonjs/components/OverlayText.d.js +6 -0
- package/lib/commonjs/components/OverlayText.d.js.map +1 -0
- package/lib/commonjs/components/OverlayText.js +45 -0
- package/lib/commonjs/components/OverlayText.js.map +1 -0
- package/lib/commonjs/components/RichTextInput.d.js +6 -0
- package/lib/commonjs/components/RichTextInput.d.js.map +1 -0
- package/lib/commonjs/components/RichTextInput.js +160 -0
- package/lib/commonjs/components/RichTextInput.js.map +1 -0
- package/lib/commonjs/components/Toolbar.d.js +6 -0
- package/lib/commonjs/components/Toolbar.d.js.map +1 -0
- package/lib/commonjs/components/Toolbar.js +99 -0
- package/lib/commonjs/components/Toolbar.js.map +1 -0
- package/lib/commonjs/components/ToolbarButton.d.js +6 -0
- package/lib/commonjs/components/ToolbarButton.d.js.map +1 -0
- package/lib/commonjs/components/ToolbarButton.js +63 -0
- package/lib/commonjs/components/ToolbarButton.js.map +1 -0
- package/lib/commonjs/constants/defaultStyles.d.js +6 -0
- package/lib/commonjs/constants/defaultStyles.d.js.map +1 -0
- package/lib/commonjs/constants/defaultStyles.js +172 -0
- package/lib/commonjs/constants/defaultStyles.js.map +1 -0
- package/lib/commonjs/context/RichTextContext.d.js +6 -0
- package/lib/commonjs/context/RichTextContext.d.js.map +1 -0
- package/lib/commonjs/context/RichTextContext.js +61 -0
- package/lib/commonjs/context/RichTextContext.js.map +1 -0
- package/lib/commonjs/hooks/useFormatting.d.js +6 -0
- package/lib/commonjs/hooks/useFormatting.d.js.map +1 -0
- package/lib/commonjs/hooks/useFormatting.js +82 -0
- package/lib/commonjs/hooks/useFormatting.js.map +1 -0
- package/lib/commonjs/hooks/useRichText.d.js +6 -0
- package/lib/commonjs/hooks/useRichText.d.js.map +1 -0
- package/lib/commonjs/hooks/useRichText.js +136 -0
- package/lib/commonjs/hooks/useRichText.js.map +1 -0
- package/lib/commonjs/hooks/useSelection.d.js +6 -0
- package/lib/commonjs/hooks/useSelection.d.js.map +1 -0
- package/lib/commonjs/hooks/useSelection.js +39 -0
- package/lib/commonjs/hooks/useSelection.js.map +1 -0
- package/lib/commonjs/index.d.js +186 -0
- package/lib/commonjs/index.d.js.map +1 -0
- package/lib/commonjs/index.js +186 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types/index.d.js +6 -0
- package/lib/commonjs/types/index.d.js.map +1 -0
- package/lib/commonjs/types/index.js +6 -0
- package/lib/commonjs/types/index.js.map +1 -0
- package/lib/commonjs/utils/formatter.d.js +13 -0
- package/lib/commonjs/utils/formatter.d.js.map +1 -0
- package/lib/commonjs/utils/formatter.js +229 -0
- package/lib/commonjs/utils/formatter.js.map +1 -0
- package/lib/commonjs/utils/parser.d.js +6 -0
- package/lib/commonjs/utils/parser.d.js.map +1 -0
- package/lib/commonjs/utils/parser.js +221 -0
- package/lib/commonjs/utils/parser.js.map +1 -0
- package/lib/commonjs/utils/styleMapper.d.js +6 -0
- package/lib/commonjs/utils/styleMapper.d.js.map +1 -0
- package/lib/commonjs/utils/styleMapper.js +87 -0
- package/lib/commonjs/utils/styleMapper.js.map +1 -0
- package/lib/module/components/OverlayText.d.js +4 -0
- package/lib/module/components/OverlayText.d.js.map +1 -0
- package/lib/module/components/OverlayText.js +41 -0
- package/lib/module/components/OverlayText.js.map +1 -0
- package/lib/module/components/RichTextInput.d.js +4 -0
- package/lib/module/components/RichTextInput.d.js.map +1 -0
- package/lib/module/components/RichTextInput.js +155 -0
- package/lib/module/components/RichTextInput.js.map +1 -0
- package/lib/module/components/Toolbar.d.js +4 -0
- package/lib/module/components/Toolbar.d.js.map +1 -0
- package/lib/module/components/Toolbar.js +95 -0
- package/lib/module/components/Toolbar.js.map +1 -0
- package/lib/module/components/ToolbarButton.d.js +4 -0
- package/lib/module/components/ToolbarButton.d.js.map +1 -0
- package/lib/module/components/ToolbarButton.js +59 -0
- package/lib/module/components/ToolbarButton.js.map +1 -0
- package/lib/module/constants/defaultStyles.d.js +4 -0
- package/lib/module/constants/defaultStyles.d.js.map +1 -0
- package/lib/module/constants/defaultStyles.js +168 -0
- package/lib/module/constants/defaultStyles.js.map +1 -0
- package/lib/module/context/RichTextContext.d.js +4 -0
- package/lib/module/context/RichTextContext.d.js.map +1 -0
- package/lib/module/context/RichTextContext.js +55 -0
- package/lib/module/context/RichTextContext.js.map +1 -0
- package/lib/module/hooks/useFormatting.d.js +11 -0
- package/lib/module/hooks/useFormatting.d.js.map +1 -0
- package/lib/module/hooks/useFormatting.js +78 -0
- package/lib/module/hooks/useFormatting.js.map +1 -0
- package/lib/module/hooks/useRichText.d.js +4 -0
- package/lib/module/hooks/useRichText.d.js.map +1 -0
- package/lib/module/hooks/useRichText.js +132 -0
- package/lib/module/hooks/useRichText.js.map +1 -0
- package/lib/module/hooks/useSelection.d.js +4 -0
- package/lib/module/hooks/useSelection.d.js.map +1 -0
- package/lib/module/hooks/useSelection.js +35 -0
- package/lib/module/hooks/useSelection.js.map +1 -0
- package/lib/module/index.d.js +15 -0
- package/lib/module/index.d.js.map +1 -0
- package/lib/module/index.js +25 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/types/index.d.js +4 -0
- package/lib/module/types/index.d.js.map +1 -0
- package/lib/module/types/index.js +4 -0
- package/lib/module/types/index.js.map +1 -0
- package/lib/module/utils/formatter.d.js +30 -0
- package/lib/module/utils/formatter.d.js.map +1 -0
- package/lib/module/utils/formatter.js +217 -0
- package/lib/module/utils/formatter.js.map +1 -0
- package/lib/module/utils/parser.d.js +4 -0
- package/lib/module/utils/parser.d.js.map +1 -0
- package/lib/module/utils/parser.js +211 -0
- package/lib/module/utils/parser.js.map +1 -0
- package/lib/module/utils/styleMapper.d.js +4 -0
- package/lib/module/utils/styleMapper.d.js.map +1 -0
- package/lib/module/utils/styleMapper.js +82 -0
- package/lib/module/utils/styleMapper.js.map +1 -0
- package/lib/typescript/src/components/OverlayText.d.ts +11 -0
- package/lib/typescript/src/components/OverlayText.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextInput.d.ts +21 -0
- package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -0
- package/lib/typescript/src/components/Toolbar.d.ts +13 -0
- package/lib/typescript/src/components/Toolbar.d.ts.map +1 -0
- package/lib/typescript/src/components/ToolbarButton.d.ts +8 -0
- package/lib/typescript/src/components/ToolbarButton.d.ts.map +1 -0
- package/lib/typescript/src/constants/defaultStyles.d.ts +46 -0
- package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -0
- package/lib/typescript/src/context/RichTextContext.d.ts +31 -0
- package/lib/typescript/src/context/RichTextContext.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useFormatting.d.ts +26 -0
- package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useRichText.d.ts +17 -0
- package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useSelection.d.ts +14 -0
- package/lib/typescript/src/hooks/useSelection.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +16 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types/index.d.ts +245 -0
- package/lib/typescript/src/types/index.d.ts.map +1 -0
- package/lib/typescript/src/utils/formatter.d.ts +29 -0
- package/lib/typescript/src/utils/formatter.d.ts.map +1 -0
- package/lib/typescript/src/utils/parser.d.ts +46 -0
- package/lib/typescript/src/utils/parser.d.ts.map +1 -0
- package/lib/typescript/src/utils/styleMapper.d.ts +16 -0
- package/lib/typescript/src/utils/styleMapper.d.ts.map +1 -0
- package/package.json +83 -0
- package/src/components/OverlayText.d.ts +10 -0
- package/src/components/OverlayText.tsx +46 -0
- package/src/components/RichTextInput.d.ts +20 -0
- package/src/components/RichTextInput.tsx +174 -0
- package/src/components/Toolbar.d.ts +12 -0
- package/src/components/Toolbar.tsx +100 -0
- package/src/components/ToolbarButton.d.ts +7 -0
- package/src/components/ToolbarButton.tsx +65 -0
- package/src/constants/defaultStyles.d.ts +45 -0
- package/src/constants/defaultStyles.ts +144 -0
- package/src/context/RichTextContext.d.ts +30 -0
- package/src/context/RichTextContext.tsx +63 -0
- package/src/hooks/useFormatting.d.ts +25 -0
- package/src/hooks/useFormatting.ts +135 -0
- package/src/hooks/useRichText.d.ts +16 -0
- package/src/hooks/useRichText.ts +171 -0
- package/src/hooks/useSelection.d.ts +13 -0
- package/src/hooks/useSelection.ts +40 -0
- package/src/index.d.ts +15 -0
- package/src/index.ts +68 -0
- package/src/types/index.d.ts +244 -0
- package/src/types/index.ts +295 -0
- package/src/utils/formatter.d.ts +28 -0
- package/src/utils/formatter.ts +276 -0
- package/src/utils/parser.d.ts +45 -0
- package/src/utils/parser.ts +252 -0
- package/src/utils/styleMapper.d.ts +15 -0
- package/src/utils/styleMapper.ts +92 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React, { useEffect, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
TextInput,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
type NativeSyntheticEvent,
|
|
7
|
+
type TextInputSelectionChangeEventData,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import type { RichTextInputProps } from '@/types';
|
|
10
|
+
import { DEFAULT_THEME } from '@/constants/defaultStyles';
|
|
11
|
+
import { segmentsToPlainText } from '@/utils/parser';
|
|
12
|
+
import { useRichText } from '@/hooks/useRichText';
|
|
13
|
+
import { OverlayText } from '@/components/OverlayText';
|
|
14
|
+
import { Toolbar } from '@/components/Toolbar';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* RichTextInput — The main rich text editor component.
|
|
18
|
+
*
|
|
19
|
+
* Uses the Overlay Technique:
|
|
20
|
+
* - A transparent `TextInput` on top captures user input and selection
|
|
21
|
+
* - A styled `<Text>` layer behind it renders the formatted content
|
|
22
|
+
* - Both share identical font metrics for pixel-perfect alignment
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <RichTextInput
|
|
27
|
+
* placeholder="Start typing..."
|
|
28
|
+
* showToolbar
|
|
29
|
+
* onChangeSegments={(segments) => console.log(segments)}
|
|
30
|
+
* />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
34
|
+
initialSegments,
|
|
35
|
+
onChangeSegments,
|
|
36
|
+
onChangeText,
|
|
37
|
+
placeholder = 'Start typing...',
|
|
38
|
+
editable = true,
|
|
39
|
+
maxLength,
|
|
40
|
+
showToolbar = true,
|
|
41
|
+
toolbarPosition = 'top',
|
|
42
|
+
toolbarItems,
|
|
43
|
+
theme,
|
|
44
|
+
multiline = true,
|
|
45
|
+
minHeight = 120,
|
|
46
|
+
maxHeight,
|
|
47
|
+
autoFocus = false,
|
|
48
|
+
textInputProps,
|
|
49
|
+
renderToolbar,
|
|
50
|
+
onReady,
|
|
51
|
+
}) => {
|
|
52
|
+
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
53
|
+
|
|
54
|
+
const { state, actions } = useRichText({
|
|
55
|
+
initialSegments,
|
|
56
|
+
onChangeSegments,
|
|
57
|
+
onChangeText,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Expose actions via onReady callback
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
onReady?.(actions);
|
|
63
|
+
}, [onReady, actions]);
|
|
64
|
+
|
|
65
|
+
// Build plain text for the TextInput value
|
|
66
|
+
const plainText = segmentsToPlainText(state.segments);
|
|
67
|
+
|
|
68
|
+
// Handle selection change from TextInput
|
|
69
|
+
const onSelectionChange = useCallback(
|
|
70
|
+
(e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
|
71
|
+
const { start, end } = e.nativeEvent.selection;
|
|
72
|
+
actions.handleSelectionChange({ start, end });
|
|
73
|
+
},
|
|
74
|
+
[actions],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Container style
|
|
78
|
+
const containerStyle = [
|
|
79
|
+
resolvedTheme.containerStyle ?? DEFAULT_THEME.containerStyle,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// Input area style
|
|
83
|
+
const inputAreaStyle = [
|
|
84
|
+
styles.inputArea,
|
|
85
|
+
{ minHeight },
|
|
86
|
+
maxHeight ? { maxHeight } : undefined,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// Input style
|
|
90
|
+
const inputStyle = [
|
|
91
|
+
styles.textInput,
|
|
92
|
+
resolvedTheme.inputStyle ?? DEFAULT_THEME.inputStyle,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// Toolbar component
|
|
96
|
+
const toolbarComponent = showToolbar ? (
|
|
97
|
+
<Toolbar
|
|
98
|
+
actions={actions}
|
|
99
|
+
state={state}
|
|
100
|
+
items={toolbarItems}
|
|
101
|
+
theme={resolvedTheme}
|
|
102
|
+
renderToolbar={renderToolbar}
|
|
103
|
+
/>
|
|
104
|
+
) : null;
|
|
105
|
+
|
|
106
|
+
// Toolbar border
|
|
107
|
+
const toolbarBorderStyle =
|
|
108
|
+
toolbarPosition === 'top'
|
|
109
|
+
? { borderBottomWidth: 1, borderBottomColor: resolvedTheme.colors?.toolbarBorder ?? DEFAULT_THEME.colors?.toolbarBorder }
|
|
110
|
+
: { borderTopWidth: 1, borderTopColor: resolvedTheme.colors?.toolbarBorder ?? DEFAULT_THEME.colors?.toolbarBorder };
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<View style={containerStyle}>
|
|
114
|
+
{/* Toolbar — Top */}
|
|
115
|
+
{toolbarPosition === 'top' && toolbarComponent && (
|
|
116
|
+
<View style={toolbarBorderStyle}>{toolbarComponent}</View>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Editor Area */}
|
|
120
|
+
<View style={inputAreaStyle}>
|
|
121
|
+
{/* Overlay — Styled text rendering (behind TextInput) */}
|
|
122
|
+
<OverlayText
|
|
123
|
+
segments={state.segments}
|
|
124
|
+
baseTextStyle={resolvedTheme.baseTextStyle}
|
|
125
|
+
theme={resolvedTheme}
|
|
126
|
+
/>
|
|
127
|
+
|
|
128
|
+
{/* TextInput — Transparent layer on top for input capture */}
|
|
129
|
+
<TextInput
|
|
130
|
+
{...textInputProps}
|
|
131
|
+
style={inputStyle}
|
|
132
|
+
value={plainText}
|
|
133
|
+
onChangeText={actions.handleTextChange}
|
|
134
|
+
onSelectionChange={onSelectionChange}
|
|
135
|
+
multiline={multiline}
|
|
136
|
+
placeholder={placeholder}
|
|
137
|
+
placeholderTextColor={
|
|
138
|
+
resolvedTheme.colors?.placeholder ??
|
|
139
|
+
DEFAULT_THEME.colors?.placeholder
|
|
140
|
+
}
|
|
141
|
+
editable={editable}
|
|
142
|
+
maxLength={maxLength}
|
|
143
|
+
autoFocus={autoFocus}
|
|
144
|
+
selectionColor={
|
|
145
|
+
resolvedTheme.colors?.cursor ?? DEFAULT_THEME.colors?.cursor
|
|
146
|
+
}
|
|
147
|
+
textAlignVertical="top"
|
|
148
|
+
scrollEnabled={true}
|
|
149
|
+
/>
|
|
150
|
+
</View>
|
|
151
|
+
|
|
152
|
+
{/* Toolbar — Bottom */}
|
|
153
|
+
{toolbarPosition === 'bottom' && toolbarComponent && (
|
|
154
|
+
<View style={toolbarBorderStyle}>{toolbarComponent}</View>
|
|
155
|
+
)}
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
RichTextInput.displayName = 'RichTextInput';
|
|
161
|
+
|
|
162
|
+
const styles = StyleSheet.create({
|
|
163
|
+
inputArea: {
|
|
164
|
+
position: 'relative',
|
|
165
|
+
},
|
|
166
|
+
textInput: {
|
|
167
|
+
// The TextInput must be transparent so the overlay text shows through.
|
|
168
|
+
// Only the caret/cursor and selection highlight are visible.
|
|
169
|
+
color: 'transparent',
|
|
170
|
+
// Ensure it matches the overlay text positioning exactly.
|
|
171
|
+
position: 'relative',
|
|
172
|
+
zIndex: 1,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ToolbarProps } from '@/types';
|
|
3
|
+
/**
|
|
4
|
+
* Formatting toolbar for the rich text editor.
|
|
5
|
+
*
|
|
6
|
+
* Supports:
|
|
7
|
+
* - Default toolbar items (bold, italic, underline, etc.)
|
|
8
|
+
* - Custom toolbar items via the `items` prop
|
|
9
|
+
* - Fully custom rendering via `renderToolbar`
|
|
10
|
+
* - Horizontal scrolling for overflow
|
|
11
|
+
*/
|
|
12
|
+
export declare const Toolbar: React.FC<ToolbarProps>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { View, ScrollView, StyleSheet } from 'react-native';
|
|
3
|
+
import type { ToolbarProps, ToolbarItem } from '@/types';
|
|
4
|
+
import { DEFAULT_THEME, DEFAULT_TOOLBAR_ITEMS } from '@/constants/defaultStyles';
|
|
5
|
+
import { ToolbarButton } from '@/components/ToolbarButton';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Formatting toolbar for the rich text editor.
|
|
9
|
+
*
|
|
10
|
+
* Supports:
|
|
11
|
+
* - Default toolbar items (bold, italic, underline, etc.)
|
|
12
|
+
* - Custom toolbar items via the `items` prop
|
|
13
|
+
* - Fully custom rendering via `renderToolbar`
|
|
14
|
+
* - Horizontal scrolling for overflow
|
|
15
|
+
*/
|
|
16
|
+
export const Toolbar: React.FC<ToolbarProps> = React.memo(
|
|
17
|
+
({ actions, state, items, theme, visible = true, renderToolbar }) => {
|
|
18
|
+
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
19
|
+
const toolbarItems = items ?? DEFAULT_TOOLBAR_ITEMS;
|
|
20
|
+
|
|
21
|
+
// Compute active state for each item
|
|
22
|
+
const enrichedItems: ToolbarItem[] = useMemo(() => {
|
|
23
|
+
return toolbarItems.map((item) => {
|
|
24
|
+
let isActive = false;
|
|
25
|
+
|
|
26
|
+
if (item.format) {
|
|
27
|
+
// Check if the format is currently active
|
|
28
|
+
const { activeStyles } = state;
|
|
29
|
+
isActive = !!activeStyles[item.format];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (item.heading) {
|
|
33
|
+
isActive = state.activeStyles.heading === item.heading;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
...item,
|
|
38
|
+
active: item.active ?? isActive,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}, [toolbarItems, state]);
|
|
42
|
+
|
|
43
|
+
// Custom render
|
|
44
|
+
if (renderToolbar) {
|
|
45
|
+
return renderToolbar({
|
|
46
|
+
items: enrichedItems,
|
|
47
|
+
state,
|
|
48
|
+
actions,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!visible) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const toolbarStyle = [
|
|
57
|
+
resolvedTheme.toolbarStyle ?? DEFAULT_THEME.toolbarStyle,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View style={toolbarStyle}>
|
|
62
|
+
<ScrollView
|
|
63
|
+
horizontal
|
|
64
|
+
showsHorizontalScrollIndicator={false}
|
|
65
|
+
keyboardShouldPersistTaps="always"
|
|
66
|
+
contentContainerStyle={styles.scrollContent}
|
|
67
|
+
>
|
|
68
|
+
{enrichedItems.map((item) => (
|
|
69
|
+
<ToolbarButton
|
|
70
|
+
key={item.id}
|
|
71
|
+
label={item.label}
|
|
72
|
+
active={!!item.active}
|
|
73
|
+
theme={resolvedTheme}
|
|
74
|
+
renderButton={item.renderButton}
|
|
75
|
+
onPress={() => {
|
|
76
|
+
if (item.onPress) {
|
|
77
|
+
item.onPress();
|
|
78
|
+
} else if (item.format) {
|
|
79
|
+
actions.toggleFormat(item.format);
|
|
80
|
+
} else if (item.heading) {
|
|
81
|
+
actions.setHeading(item.heading);
|
|
82
|
+
}
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
))}
|
|
86
|
+
</ScrollView>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
Toolbar.displayName = 'Toolbar';
|
|
93
|
+
|
|
94
|
+
const styles = StyleSheet.create({
|
|
95
|
+
scrollContent: {
|
|
96
|
+
flexDirection: 'row',
|
|
97
|
+
alignItems: 'center',
|
|
98
|
+
gap: 2,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ToolbarButtonProps } from '@/types';
|
|
3
|
+
/**
|
|
4
|
+
* A single toolbar button that toggles a formatting option.
|
|
5
|
+
* Supports custom rendering via the `renderButton` prop.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ToolbarButton: React.FC<ToolbarButtonProps>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
|
|
3
|
+
import type { ToolbarButtonProps } from '@/types';
|
|
4
|
+
import { DEFAULT_THEME } from '@/constants/defaultStyles';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A single toolbar button that toggles a formatting option.
|
|
8
|
+
* Supports custom rendering via the `renderButton` prop.
|
|
9
|
+
*/
|
|
10
|
+
export const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo(
|
|
11
|
+
({ label, active, onPress, theme, renderButton }) => {
|
|
12
|
+
// Custom render
|
|
13
|
+
if (renderButton) {
|
|
14
|
+
return renderButton({ active, onPress, label });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
18
|
+
|
|
19
|
+
const buttonStyle = [
|
|
20
|
+
resolvedTheme.toolbarButtonStyle ?? DEFAULT_THEME.toolbarButtonStyle,
|
|
21
|
+
active &&
|
|
22
|
+
(resolvedTheme.toolbarButtonActiveStyle ??
|
|
23
|
+
DEFAULT_THEME.toolbarButtonActiveStyle),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const textStyle = [
|
|
27
|
+
resolvedTheme.toolbarButtonTextStyle ??
|
|
28
|
+
DEFAULT_THEME.toolbarButtonTextStyle,
|
|
29
|
+
active &&
|
|
30
|
+
(resolvedTheme.toolbarButtonActiveTextStyle ??
|
|
31
|
+
DEFAULT_THEME.toolbarButtonActiveTextStyle),
|
|
32
|
+
// Make italic button actually italic, bold button actually bold, etc.
|
|
33
|
+
label === 'I' && styles.italicLabel,
|
|
34
|
+
label === 'U' && styles.underlineLabel,
|
|
35
|
+
label === 'S' && styles.strikethroughLabel,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<TouchableOpacity
|
|
40
|
+
style={buttonStyle}
|
|
41
|
+
onPress={onPress}
|
|
42
|
+
activeOpacity={0.7}
|
|
43
|
+
accessibilityRole="button"
|
|
44
|
+
accessibilityLabel={`Format ${label}`}
|
|
45
|
+
accessibilityState={{ selected: active }}
|
|
46
|
+
>
|
|
47
|
+
<Text style={textStyle}>{label}</Text>
|
|
48
|
+
</TouchableOpacity>
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
ToolbarButton.displayName = 'ToolbarButton';
|
|
54
|
+
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
italicLabel: {
|
|
57
|
+
fontStyle: 'italic',
|
|
58
|
+
},
|
|
59
|
+
underlineLabel: {
|
|
60
|
+
textDecorationLine: 'underline',
|
|
61
|
+
},
|
|
62
|
+
strikethroughLabel: {
|
|
63
|
+
textDecorationLine: 'line-through',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RichTextTheme, FormatStyle, ToolbarItem } from '@/types';
|
|
2
|
+
/**
|
|
3
|
+
* Default color palette used throughout the editor.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_COLORS: {
|
|
6
|
+
readonly primary: "#6366F1";
|
|
7
|
+
readonly background: "#FFFFFF";
|
|
8
|
+
readonly text: "#1F2937";
|
|
9
|
+
readonly placeholder: "#9CA3AF";
|
|
10
|
+
readonly toolbarBackground: "#F9FAFB";
|
|
11
|
+
readonly toolbarBorder: "#E5E7EB";
|
|
12
|
+
readonly cursor: "#6366F1";
|
|
13
|
+
readonly activeButtonBg: "#EEF2FF";
|
|
14
|
+
readonly codeBackground: "#F3F4F6";
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Font size presets for heading levels.
|
|
18
|
+
*/
|
|
19
|
+
export declare const HEADING_FONT_SIZES: {
|
|
20
|
+
readonly h1: 32;
|
|
21
|
+
readonly h2: 24;
|
|
22
|
+
readonly h3: 20;
|
|
23
|
+
readonly none: 16;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Default base text style applied to all segments.
|
|
27
|
+
*/
|
|
28
|
+
export declare const DEFAULT_BASE_TEXT_STYLE: {
|
|
29
|
+
readonly fontSize: 16;
|
|
30
|
+
readonly lineHeight: 24;
|
|
31
|
+
readonly color: "#1F2937";
|
|
32
|
+
readonly fontFamily: undefined;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Empty format style — no formatting applied.
|
|
36
|
+
*/
|
|
37
|
+
export declare const EMPTY_FORMAT_STYLE: FormatStyle;
|
|
38
|
+
/**
|
|
39
|
+
* Default theme configuration.
|
|
40
|
+
*/
|
|
41
|
+
export declare const DEFAULT_THEME: RichTextTheme;
|
|
42
|
+
/**
|
|
43
|
+
* Default toolbar items for the built-in toolbar.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_TOOLBAR_ITEMS: ToolbarItem[];
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { RichTextTheme, FormatStyle, ToolbarItem } from '@/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default color palette used throughout the editor.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_COLORS = {
|
|
7
|
+
primary: '#6366F1',
|
|
8
|
+
background: '#FFFFFF',
|
|
9
|
+
text: '#1F2937',
|
|
10
|
+
placeholder: '#9CA3AF',
|
|
11
|
+
toolbarBackground: '#F9FAFB',
|
|
12
|
+
toolbarBorder: '#E5E7EB',
|
|
13
|
+
cursor: '#6366F1',
|
|
14
|
+
activeButtonBg: '#EEF2FF',
|
|
15
|
+
codeBackground: '#F3F4F6',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Font size presets for heading levels.
|
|
20
|
+
*/
|
|
21
|
+
export const HEADING_FONT_SIZES = {
|
|
22
|
+
h1: 32,
|
|
23
|
+
h2: 24,
|
|
24
|
+
h3: 20,
|
|
25
|
+
none: 16,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default base text style applied to all segments.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_BASE_TEXT_STYLE = {
|
|
32
|
+
fontSize: 16,
|
|
33
|
+
lineHeight: 24,
|
|
34
|
+
color: DEFAULT_COLORS.text,
|
|
35
|
+
fontFamily: undefined, // Uses system default
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Empty format style — no formatting applied.
|
|
40
|
+
*/
|
|
41
|
+
export const EMPTY_FORMAT_STYLE: FormatStyle = {
|
|
42
|
+
bold: false,
|
|
43
|
+
italic: false,
|
|
44
|
+
underline: false,
|
|
45
|
+
strikethrough: false,
|
|
46
|
+
code: false,
|
|
47
|
+
color: undefined,
|
|
48
|
+
backgroundColor: undefined,
|
|
49
|
+
fontSize: undefined,
|
|
50
|
+
heading: undefined,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default theme configuration.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_THEME: RichTextTheme = {
|
|
57
|
+
containerStyle: {
|
|
58
|
+
borderWidth: 1,
|
|
59
|
+
borderColor: DEFAULT_COLORS.toolbarBorder,
|
|
60
|
+
borderRadius: 12,
|
|
61
|
+
backgroundColor: DEFAULT_COLORS.background,
|
|
62
|
+
overflow: 'hidden',
|
|
63
|
+
},
|
|
64
|
+
inputStyle: {
|
|
65
|
+
fontSize: DEFAULT_BASE_TEXT_STYLE.fontSize,
|
|
66
|
+
lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight,
|
|
67
|
+
color: 'transparent',
|
|
68
|
+
paddingHorizontal: 16,
|
|
69
|
+
paddingVertical: 12,
|
|
70
|
+
textAlignVertical: 'top',
|
|
71
|
+
},
|
|
72
|
+
overlayContainerStyle: {
|
|
73
|
+
position: 'absolute',
|
|
74
|
+
top: 0,
|
|
75
|
+
left: 0,
|
|
76
|
+
right: 0,
|
|
77
|
+
bottom: 0,
|
|
78
|
+
paddingHorizontal: 16,
|
|
79
|
+
paddingVertical: 12,
|
|
80
|
+
},
|
|
81
|
+
baseTextStyle: {
|
|
82
|
+
fontSize: DEFAULT_BASE_TEXT_STYLE.fontSize,
|
|
83
|
+
lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight,
|
|
84
|
+
color: DEFAULT_COLORS.text,
|
|
85
|
+
},
|
|
86
|
+
toolbarStyle: {
|
|
87
|
+
flexDirection: 'row',
|
|
88
|
+
alignItems: 'center',
|
|
89
|
+
paddingHorizontal: 8,
|
|
90
|
+
paddingVertical: 6,
|
|
91
|
+
backgroundColor: DEFAULT_COLORS.toolbarBackground,
|
|
92
|
+
borderColor: DEFAULT_COLORS.toolbarBorder,
|
|
93
|
+
gap: 2,
|
|
94
|
+
},
|
|
95
|
+
toolbarButtonStyle: {
|
|
96
|
+
paddingHorizontal: 10,
|
|
97
|
+
paddingVertical: 6,
|
|
98
|
+
borderRadius: 6,
|
|
99
|
+
minWidth: 36,
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'center',
|
|
102
|
+
},
|
|
103
|
+
toolbarButtonActiveStyle: {
|
|
104
|
+
backgroundColor: DEFAULT_COLORS.activeButtonBg,
|
|
105
|
+
},
|
|
106
|
+
toolbarButtonTextStyle: {
|
|
107
|
+
fontSize: 15,
|
|
108
|
+
fontWeight: '600',
|
|
109
|
+
color: DEFAULT_COLORS.text,
|
|
110
|
+
},
|
|
111
|
+
toolbarButtonActiveTextStyle: {
|
|
112
|
+
color: DEFAULT_COLORS.primary,
|
|
113
|
+
},
|
|
114
|
+
codeStyle: {
|
|
115
|
+
fontFamily: 'monospace',
|
|
116
|
+
backgroundColor: DEFAULT_COLORS.codeBackground,
|
|
117
|
+
paddingHorizontal: 4,
|
|
118
|
+
borderRadius: 4,
|
|
119
|
+
fontSize: 14,
|
|
120
|
+
},
|
|
121
|
+
colors: {
|
|
122
|
+
primary: DEFAULT_COLORS.primary,
|
|
123
|
+
background: DEFAULT_COLORS.background,
|
|
124
|
+
text: DEFAULT_COLORS.text,
|
|
125
|
+
placeholder: DEFAULT_COLORS.placeholder,
|
|
126
|
+
toolbarBackground: DEFAULT_COLORS.toolbarBackground,
|
|
127
|
+
toolbarBorder: DEFAULT_COLORS.toolbarBorder,
|
|
128
|
+
cursor: DEFAULT_COLORS.cursor,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Default toolbar items for the built-in toolbar.
|
|
134
|
+
*/
|
|
135
|
+
export const DEFAULT_TOOLBAR_ITEMS: ToolbarItem[] = [
|
|
136
|
+
{ id: 'bold', label: 'B', format: 'bold' },
|
|
137
|
+
{ id: 'italic', label: 'I', format: 'italic' },
|
|
138
|
+
{ id: 'underline', label: 'U', format: 'underline' },
|
|
139
|
+
{ id: 'strikethrough', label: 'S', format: 'strikethrough' },
|
|
140
|
+
{ id: 'code', label: '<>', format: 'code' },
|
|
141
|
+
{ id: 'h1', label: 'H1', heading: 'h1' },
|
|
142
|
+
{ id: 'h2', label: 'H2', heading: 'h2' },
|
|
143
|
+
{ id: 'h3', label: 'H3', heading: 'h3' },
|
|
144
|
+
];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { UseRichTextReturn } from '@/types';
|
|
3
|
+
import { type UseRichTextOptions } from '@/hooks/useRichText';
|
|
4
|
+
export interface RichTextProviderProps extends UseRichTextOptions {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* RichTextProvider wraps children with rich text state via React Context.
|
|
9
|
+
*
|
|
10
|
+
* Use this when you need to access the rich text state/actions from
|
|
11
|
+
* deeply nested components (e.g., a custom toolbar in a different part
|
|
12
|
+
* of the component tree).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <RichTextProvider onChangeSegments={handleChange}>
|
|
17
|
+
* <MyCustomToolbar />
|
|
18
|
+
* <RichTextInput showToolbar={false} />
|
|
19
|
+
* </RichTextProvider>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare const RichTextProvider: React.FC<RichTextProviderProps>;
|
|
23
|
+
/**
|
|
24
|
+
* Hook to access the RichText state and actions from context.
|
|
25
|
+
*
|
|
26
|
+
* Must be used within a `<RichTextProvider>`.
|
|
27
|
+
*
|
|
28
|
+
* @throws If used outside of a RichTextProvider
|
|
29
|
+
*/
|
|
30
|
+
export declare function useRichTextContext(): UseRichTextReturn;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import type { RichTextState, RichTextActions, UseRichTextReturn } from '@/types';
|
|
3
|
+
import { useRichText, type UseRichTextOptions } from '@/hooks/useRichText';
|
|
4
|
+
|
|
5
|
+
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const RichTextContext = createContext<UseRichTextReturn | null>(null);
|
|
8
|
+
|
|
9
|
+
// ─── Provider ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface RichTextProviderProps extends UseRichTextOptions {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* RichTextProvider wraps children with rich text state via React Context.
|
|
17
|
+
*
|
|
18
|
+
* Use this when you need to access the rich text state/actions from
|
|
19
|
+
* deeply nested components (e.g., a custom toolbar in a different part
|
|
20
|
+
* of the component tree).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <RichTextProvider onChangeSegments={handleChange}>
|
|
25
|
+
* <MyCustomToolbar />
|
|
26
|
+
* <RichTextInput showToolbar={false} />
|
|
27
|
+
* </RichTextProvider>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export const RichTextProvider: React.FC<RichTextProviderProps> = ({
|
|
31
|
+
children,
|
|
32
|
+
...options
|
|
33
|
+
}) => {
|
|
34
|
+
const richText = useRichText(options);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<RichTextContext.Provider value={richText}>
|
|
38
|
+
{children}
|
|
39
|
+
</RichTextContext.Provider>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
RichTextProvider.displayName = 'RichTextProvider';
|
|
44
|
+
|
|
45
|
+
// ─── Consumer Hook ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hook to access the RichText state and actions from context.
|
|
49
|
+
*
|
|
50
|
+
* Must be used within a `<RichTextProvider>`.
|
|
51
|
+
*
|
|
52
|
+
* @throws If used outside of a RichTextProvider
|
|
53
|
+
*/
|
|
54
|
+
export function useRichTextContext(): UseRichTextReturn {
|
|
55
|
+
const context = useContext(RichTextContext);
|
|
56
|
+
if (!context) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'useRichTextContext must be used within a <RichTextProvider>. ' +
|
|
59
|
+
'Wrap your component tree with <RichTextProvider> to use this hook.',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return context;
|
|
63
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { StyledSegment, FormatType, FormatStyle, HeadingLevel, SelectionRange } from '@/types';
|
|
2
|
+
interface UseFormattingOptions {
|
|
3
|
+
segments: StyledSegment[];
|
|
4
|
+
selection: SelectionRange;
|
|
5
|
+
activeStyles: FormatStyle;
|
|
6
|
+
onSegmentsChange: (segments: StyledSegment[]) => void;
|
|
7
|
+
onActiveStylesChange: (styles: FormatStyle) => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Hook that provides formatting commands for the rich text editor.
|
|
11
|
+
*
|
|
12
|
+
* Handles both selection-based formatting (when text is selected)
|
|
13
|
+
* and active-style updates (when no text is selected — affects next typed text).
|
|
14
|
+
*/
|
|
15
|
+
export declare function useFormatting({ segments, selection, activeStyles, onSegmentsChange, onActiveStylesChange, }: UseFormattingOptions): {
|
|
16
|
+
toggleFormat: (format: FormatType) => void;
|
|
17
|
+
setStyleProperty: <K extends keyof FormatStyle>(key: K, value: FormatStyle[K]) => void;
|
|
18
|
+
setHeading: (level: HeadingLevel) => void;
|
|
19
|
+
setColor: (color: string) => void;
|
|
20
|
+
setBackgroundColor: (color: string) => void;
|
|
21
|
+
setFontSize: (size: number) => void;
|
|
22
|
+
isFormatActive: (format: FormatType) => boolean;
|
|
23
|
+
currentSelectionStyle: () => FormatStyle;
|
|
24
|
+
};
|
|
25
|
+
export {};
|