react-native-richify 1.0.3 → 1.0.4
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/lib/commonjs/components/RichTextInput.js +71 -46
- package/lib/commonjs/components/RichTextInput.js.map +1 -1
- package/lib/commonjs/constants/defaultStyles.js +26 -1
- package/lib/commonjs/constants/defaultStyles.js.map +1 -1
- package/lib/commonjs/hooks/useFormatting.js +7 -1
- package/lib/commonjs/hooks/useFormatting.js.map +1 -1
- package/lib/commonjs/hooks/useRichText.js +55 -6
- package/lib/commonjs/hooks/useRichText.js.map +1 -1
- package/lib/commonjs/index.d.js +19 -0
- package/lib/commonjs/index.d.js.map +1 -1
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/utils/formatter.js +1 -4
- package/lib/commonjs/utils/formatter.js.map +1 -1
- package/lib/commonjs/utils/serializer.d.js +6 -0
- package/lib/commonjs/utils/serializer.d.js.map +1 -0
- package/lib/commonjs/utils/serializer.js +163 -0
- package/lib/commonjs/utils/serializer.js.map +1 -0
- package/lib/module/components/RichTextInput.js +73 -49
- package/lib/module/components/RichTextInput.js.map +1 -1
- package/lib/module/constants/defaultStyles.js +26 -1
- package/lib/module/constants/defaultStyles.js.map +1 -1
- package/lib/module/hooks/useFormatting.js +7 -1
- package/lib/module/hooks/useFormatting.js.map +1 -1
- package/lib/module/hooks/useRichText.js +55 -6
- package/lib/module/hooks/useRichText.js.map +1 -1
- package/lib/module/index.d.js +1 -0
- package/lib/module/index.d.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/formatter.js +1 -4
- package/lib/module/utils/formatter.js.map +1 -1
- package/lib/module/utils/serializer.d.js +4 -0
- package/lib/module/utils/serializer.d.js.map +1 -0
- package/lib/module/utils/serializer.js +157 -0
- package/lib/module/utils/serializer.js.map +1 -0
- package/lib/typescript/src/components/RichTextInput.d.ts +2 -13
- package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -1
- package/lib/typescript/src/constants/defaultStyles.d.ts +2 -0
- package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types/index.d.ts +19 -1
- package/lib/typescript/src/types/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/formatter.d.ts.map +1 -1
- package/lib/typescript/src/utils/serializer.d.ts +14 -0
- package/lib/typescript/src/utils/serializer.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/RichTextInput.d.ts +3 -14
- package/src/components/RichTextInput.tsx +107 -50
- package/src/constants/defaultStyles.d.ts +3 -1
- package/src/constants/defaultStyles.ts +26 -1
- package/src/hooks/useFormatting.ts +14 -1
- package/src/hooks/useRichText.ts +92 -6
- package/src/index.d.ts +2 -1
- package/src/index.ts +6 -0
- package/src/types/index.d.ts +19 -1
- package/src/types/index.ts +20 -1
- package/src/utils/formatter.ts +1 -5
- package/src/utils/serializer.d.ts +13 -0
- package/src/utils/serializer.ts +223 -0
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { RichTextInputProps } from '../types';
|
|
3
3
|
/**
|
|
4
|
-
* RichTextInput
|
|
4
|
+
* RichTextInput — The main rich text editor component.
|
|
5
5
|
*
|
|
6
|
-
* Uses the
|
|
7
|
-
*
|
|
8
|
-
* - A styled `<Text>` layer behind it renders the formatted content
|
|
9
|
-
* - Both share identical font metrics for pixel-perfect alignment
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* ```tsx
|
|
13
|
-
* <RichTextInput
|
|
14
|
-
* placeholder="Start typing..."
|
|
15
|
-
* showToolbar
|
|
16
|
-
* onChangeSegments={(segments) => console.log(segments)}
|
|
17
|
-
* />
|
|
18
|
-
* ```
|
|
6
|
+
* Uses a plain `TextInput` for editing and renders the serialized rich output
|
|
7
|
+
* below it as Markdown or HTML.
|
|
19
8
|
*/
|
|
20
9
|
export declare const RichTextInput: React.FC<RichTextInputProps>;
|
|
@@ -1,34 +1,35 @@
|
|
|
1
|
-
import React, { useEffect, useCallback } from 'react';
|
|
1
|
+
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Animated,
|
|
4
|
+
Easing,
|
|
5
|
+
ScrollView,
|
|
5
6
|
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
TextInput,
|
|
9
|
+
View,
|
|
6
10
|
type NativeSyntheticEvent,
|
|
7
11
|
type TextInputSelectionChangeEventData,
|
|
8
12
|
} from 'react-native';
|
|
9
13
|
import type { RichTextInputProps } from '../types';
|
|
10
14
|
import { DEFAULT_THEME } from '../constants/defaultStyles';
|
|
11
|
-
import { segmentsToPlainText } from '../utils/parser';
|
|
12
15
|
import { useRichText } from '../hooks/useRichText';
|
|
13
|
-
import {
|
|
16
|
+
import { segmentsToPlainText } from '../utils/parser';
|
|
17
|
+
import { serializeSegments } from '../utils/serializer';
|
|
14
18
|
import { Toolbar } from './Toolbar';
|
|
15
19
|
|
|
20
|
+
const OUTPUT_PANEL_HEIGHT = 180;
|
|
21
|
+
const isJestRuntime =
|
|
22
|
+
typeof (
|
|
23
|
+
globalThis as {
|
|
24
|
+
process?: { env?: { JEST_WORKER_ID?: string } };
|
|
25
|
+
}
|
|
26
|
+
).process?.env?.JEST_WORKER_ID === 'string';
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* RichTextInput — The main rich text editor component.
|
|
18
30
|
*
|
|
19
|
-
* Uses the
|
|
20
|
-
*
|
|
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
|
-
* ```
|
|
31
|
+
* Uses a plain `TextInput` for editing and renders the serialized rich output
|
|
32
|
+
* below it as Markdown or HTML.
|
|
32
33
|
*/
|
|
33
34
|
export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
34
35
|
initialSegments,
|
|
@@ -41,6 +42,9 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
41
42
|
toolbarPosition = 'top',
|
|
42
43
|
toolbarItems,
|
|
43
44
|
theme,
|
|
45
|
+
showOutputPreview = true,
|
|
46
|
+
outputFormat = 'markdown',
|
|
47
|
+
onChangeOutput,
|
|
44
48
|
multiline = true,
|
|
45
49
|
minHeight = 120,
|
|
46
50
|
maxHeight,
|
|
@@ -50,6 +54,7 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
50
54
|
onReady,
|
|
51
55
|
}) => {
|
|
52
56
|
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
57
|
+
const previewProgress = useRef(new Animated.Value(0)).current;
|
|
53
58
|
|
|
54
59
|
const { state, actions } = useRichText({
|
|
55
60
|
initialSegments,
|
|
@@ -57,15 +62,35 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
57
62
|
onChangeText,
|
|
58
63
|
});
|
|
59
64
|
|
|
60
|
-
// Expose actions via onReady callback
|
|
61
65
|
useEffect(() => {
|
|
62
66
|
onReady?.(actions);
|
|
63
|
-
}, [
|
|
67
|
+
}, [actions, onReady]);
|
|
64
68
|
|
|
65
|
-
// Build plain text for the TextInput value
|
|
66
69
|
const plainText = segmentsToPlainText(state.segments);
|
|
70
|
+
const serializedOutput = useMemo(
|
|
71
|
+
() => serializeSegments(state.segments, outputFormat),
|
|
72
|
+
[outputFormat, state.segments],
|
|
73
|
+
);
|
|
74
|
+
const shouldShowOutputPreview = showOutputPreview && plainText.length > 0;
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
onChangeOutput?.(serializedOutput, outputFormat);
|
|
78
|
+
}, [onChangeOutput, outputFormat, serializedOutput]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (isJestRuntime) {
|
|
82
|
+
previewProgress.setValue(shouldShowOutputPreview ? 1 : 0);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Animated.timing(previewProgress, {
|
|
87
|
+
toValue: shouldShowOutputPreview ? 1 : 0,
|
|
88
|
+
duration: 180,
|
|
89
|
+
easing: Easing.out(Easing.cubic),
|
|
90
|
+
useNativeDriver: false,
|
|
91
|
+
}).start();
|
|
92
|
+
}, [previewProgress, shouldShowOutputPreview]);
|
|
67
93
|
|
|
68
|
-
// Handle selection change from TextInput
|
|
69
94
|
const onSelectionChange = useCallback(
|
|
70
95
|
(e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
|
71
96
|
const { start, end } = e.nativeEvent.selection;
|
|
@@ -74,28 +99,49 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
74
99
|
[actions],
|
|
75
100
|
);
|
|
76
101
|
|
|
77
|
-
// Container style
|
|
78
102
|
const containerStyle = [
|
|
79
103
|
resolvedTheme.containerStyle ?? DEFAULT_THEME.containerStyle,
|
|
80
104
|
];
|
|
81
|
-
|
|
82
|
-
// Input area style
|
|
83
105
|
const inputAreaStyle = [
|
|
84
106
|
styles.inputArea,
|
|
85
107
|
{ minHeight },
|
|
86
108
|
maxHeight ? { maxHeight } : undefined,
|
|
87
109
|
];
|
|
88
|
-
|
|
89
|
-
// Input style
|
|
90
110
|
const inputStyle = [
|
|
91
111
|
styles.textInput,
|
|
92
112
|
resolvedTheme.baseTextStyle ?? DEFAULT_THEME.baseTextStyle,
|
|
93
113
|
resolvedTheme.inputStyle ?? DEFAULT_THEME.inputStyle,
|
|
94
114
|
textInputProps?.style,
|
|
95
|
-
|
|
115
|
+
];
|
|
116
|
+
const outputAnimatedStyle = {
|
|
117
|
+
maxHeight: previewProgress.interpolate({
|
|
118
|
+
inputRange: [0, 1],
|
|
119
|
+
outputRange: [0, OUTPUT_PANEL_HEIGHT],
|
|
120
|
+
}),
|
|
121
|
+
opacity: previewProgress,
|
|
122
|
+
marginTop: previewProgress.interpolate({
|
|
123
|
+
inputRange: [0, 1],
|
|
124
|
+
outputRange: [0, 12],
|
|
125
|
+
}),
|
|
126
|
+
transform: [
|
|
127
|
+
{
|
|
128
|
+
translateY: previewProgress.interpolate({
|
|
129
|
+
inputRange: [0, 1],
|
|
130
|
+
outputRange: [-8, 0],
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
const outputContainerStyle = [
|
|
136
|
+
resolvedTheme.outputContainerStyle ?? DEFAULT_THEME.outputContainerStyle,
|
|
137
|
+
];
|
|
138
|
+
const outputLabelStyle = [
|
|
139
|
+
resolvedTheme.outputLabelStyle ?? DEFAULT_THEME.outputLabelStyle,
|
|
140
|
+
];
|
|
141
|
+
const outputTextStyle = [
|
|
142
|
+
resolvedTheme.outputTextStyle ?? DEFAULT_THEME.outputTextStyle,
|
|
96
143
|
];
|
|
97
144
|
|
|
98
|
-
// Toolbar component
|
|
99
145
|
const toolbarComponent = showToolbar ? (
|
|
100
146
|
<Toolbar
|
|
101
147
|
actions={actions}
|
|
@@ -106,29 +152,28 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
106
152
|
/>
|
|
107
153
|
) : null;
|
|
108
154
|
|
|
109
|
-
// Toolbar border
|
|
110
155
|
const toolbarBorderStyle =
|
|
111
156
|
toolbarPosition === 'top'
|
|
112
|
-
? {
|
|
113
|
-
|
|
157
|
+
? {
|
|
158
|
+
borderBottomWidth: 1,
|
|
159
|
+
borderBottomColor:
|
|
160
|
+
resolvedTheme.colors?.toolbarBorder ??
|
|
161
|
+
DEFAULT_THEME.colors?.toolbarBorder,
|
|
162
|
+
}
|
|
163
|
+
: {
|
|
164
|
+
borderTopWidth: 1,
|
|
165
|
+
borderTopColor:
|
|
166
|
+
resolvedTheme.colors?.toolbarBorder ??
|
|
167
|
+
DEFAULT_THEME.colors?.toolbarBorder,
|
|
168
|
+
};
|
|
114
169
|
|
|
115
170
|
return (
|
|
116
171
|
<View style={containerStyle}>
|
|
117
|
-
{/* Toolbar — Top */}
|
|
118
172
|
{toolbarPosition === 'top' && toolbarComponent && (
|
|
119
173
|
<View style={toolbarBorderStyle}>{toolbarComponent}</View>
|
|
120
174
|
)}
|
|
121
175
|
|
|
122
|
-
{/* Editor Area */}
|
|
123
176
|
<View style={inputAreaStyle}>
|
|
124
|
-
{/* Overlay — Styled text rendering (behind TextInput) */}
|
|
125
|
-
<OverlayText
|
|
126
|
-
segments={state.segments}
|
|
127
|
-
baseTextStyle={resolvedTheme.baseTextStyle}
|
|
128
|
-
theme={resolvedTheme}
|
|
129
|
-
/>
|
|
130
|
-
|
|
131
|
-
{/* TextInput — Transparent layer on top for input capture */}
|
|
132
177
|
<TextInput
|
|
133
178
|
{...textInputProps}
|
|
134
179
|
style={inputStyle}
|
|
@@ -144,16 +189,32 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
144
189
|
editable={editable}
|
|
145
190
|
maxLength={maxLength}
|
|
146
191
|
autoFocus={autoFocus}
|
|
147
|
-
underlineColorAndroid="transparent"
|
|
148
192
|
selectionColor={
|
|
149
193
|
resolvedTheme.colors?.cursor ?? DEFAULT_THEME.colors?.cursor
|
|
150
194
|
}
|
|
151
195
|
textAlignVertical="top"
|
|
152
196
|
scrollEnabled={typeof maxHeight === 'number'}
|
|
153
197
|
/>
|
|
198
|
+
|
|
199
|
+
{showOutputPreview && (
|
|
200
|
+
<Animated.View
|
|
201
|
+
pointerEvents={shouldShowOutputPreview ? 'auto' : 'none'}
|
|
202
|
+
style={[styles.outputAnimatedWrapper, outputAnimatedStyle]}
|
|
203
|
+
>
|
|
204
|
+
<View style={outputContainerStyle}>
|
|
205
|
+
<Text style={outputLabelStyle}>
|
|
206
|
+
{outputFormat === 'html' ? 'HTML output' : 'Markdown output'}
|
|
207
|
+
</Text>
|
|
208
|
+
<ScrollView showsVerticalScrollIndicator={false}>
|
|
209
|
+
<Text selectable style={outputTextStyle}>
|
|
210
|
+
{serializedOutput}
|
|
211
|
+
</Text>
|
|
212
|
+
</ScrollView>
|
|
213
|
+
</View>
|
|
214
|
+
</Animated.View>
|
|
215
|
+
)}
|
|
154
216
|
</View>
|
|
155
217
|
|
|
156
|
-
{/* Toolbar — Bottom */}
|
|
157
218
|
{toolbarPosition === 'bottom' && toolbarComponent && (
|
|
158
219
|
<View style={toolbarBorderStyle}>{toolbarComponent}</View>
|
|
159
220
|
)}
|
|
@@ -169,12 +230,8 @@ const styles = StyleSheet.create({
|
|
|
169
230
|
},
|
|
170
231
|
textInput: {
|
|
171
232
|
position: 'relative',
|
|
172
|
-
zIndex: 1,
|
|
173
233
|
},
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
color: 'transparent',
|
|
177
|
-
backgroundColor: 'transparent',
|
|
178
|
-
textShadowColor: 'transparent',
|
|
234
|
+
outputAnimatedWrapper: {
|
|
235
|
+
overflow: 'hidden',
|
|
179
236
|
},
|
|
180
237
|
});
|
|
@@ -9,6 +9,8 @@ export declare const DEFAULT_COLORS: {
|
|
|
9
9
|
readonly placeholder: "#9CA3AF";
|
|
10
10
|
readonly toolbarBackground: "#F9FAFB";
|
|
11
11
|
readonly toolbarBorder: "#E5E7EB";
|
|
12
|
+
readonly outputBackground: "#F8FAFC";
|
|
13
|
+
readonly outputLabel: "#475569";
|
|
12
14
|
readonly cursor: "#6366F1";
|
|
13
15
|
readonly activeButtonBg: "#EEF2FF";
|
|
14
16
|
readonly codeBackground: "#F3F4F6";
|
|
@@ -32,7 +34,7 @@ export declare const DEFAULT_BASE_TEXT_STYLE: {
|
|
|
32
34
|
readonly fontFamily: undefined;
|
|
33
35
|
};
|
|
34
36
|
/**
|
|
35
|
-
* Empty format style
|
|
37
|
+
* Empty format style — no formatting applied.
|
|
36
38
|
*/
|
|
37
39
|
export declare const EMPTY_FORMAT_STYLE: FormatStyle;
|
|
38
40
|
/**
|
|
@@ -10,6 +10,8 @@ export const DEFAULT_COLORS = {
|
|
|
10
10
|
placeholder: '#9CA3AF',
|
|
11
11
|
toolbarBackground: '#F9FAFB',
|
|
12
12
|
toolbarBorder: '#E5E7EB',
|
|
13
|
+
outputBackground: '#F8FAFC',
|
|
14
|
+
outputLabel: '#475569',
|
|
13
15
|
cursor: '#6366F1',
|
|
14
16
|
activeButtonBg: '#EEF2FF',
|
|
15
17
|
codeBackground: '#F3F4F6',
|
|
@@ -64,7 +66,7 @@ export const DEFAULT_THEME: RichTextTheme = {
|
|
|
64
66
|
inputStyle: {
|
|
65
67
|
fontSize: DEFAULT_BASE_TEXT_STYLE.fontSize,
|
|
66
68
|
lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight,
|
|
67
|
-
color:
|
|
69
|
+
color: DEFAULT_COLORS.text,
|
|
68
70
|
paddingHorizontal: 16,
|
|
69
71
|
paddingVertical: 12,
|
|
70
72
|
textAlignVertical: 'top',
|
|
@@ -83,6 +85,29 @@ export const DEFAULT_THEME: RichTextTheme = {
|
|
|
83
85
|
lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight,
|
|
84
86
|
color: DEFAULT_COLORS.text,
|
|
85
87
|
},
|
|
88
|
+
outputContainerStyle: {
|
|
89
|
+
marginHorizontal: 12,
|
|
90
|
+
marginBottom: 12,
|
|
91
|
+
padding: 12,
|
|
92
|
+
borderRadius: 10,
|
|
93
|
+
borderWidth: 1,
|
|
94
|
+
borderColor: DEFAULT_COLORS.toolbarBorder,
|
|
95
|
+
backgroundColor: DEFAULT_COLORS.outputBackground,
|
|
96
|
+
},
|
|
97
|
+
outputLabelStyle: {
|
|
98
|
+
marginBottom: 8,
|
|
99
|
+
fontSize: 12,
|
|
100
|
+
fontWeight: '700',
|
|
101
|
+
letterSpacing: 0.4,
|
|
102
|
+
color: DEFAULT_COLORS.outputLabel,
|
|
103
|
+
textTransform: 'uppercase',
|
|
104
|
+
},
|
|
105
|
+
outputTextStyle: {
|
|
106
|
+
fontSize: 14,
|
|
107
|
+
lineHeight: 20,
|
|
108
|
+
color: DEFAULT_COLORS.text,
|
|
109
|
+
fontFamily: 'monospace',
|
|
110
|
+
},
|
|
86
111
|
toolbarStyle: {
|
|
87
112
|
flexDirection: 'row',
|
|
88
113
|
alignItems: 'center',
|
|
@@ -78,10 +78,23 @@ export function useFormatting({
|
|
|
78
78
|
|
|
79
79
|
const setHeading = useCallback(
|
|
80
80
|
(level: HeadingLevel) => {
|
|
81
|
+
if (selection.start === selection.end) {
|
|
82
|
+
onActiveStylesChange({
|
|
83
|
+
...activeStyles,
|
|
84
|
+
heading: level === 'none' ? undefined : level,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
81
88
|
const newSegments = setHeadingOnLine(segments, selection, level);
|
|
82
89
|
onSegmentsChange(newSegments);
|
|
83
90
|
},
|
|
84
|
-
[
|
|
91
|
+
[
|
|
92
|
+
activeStyles,
|
|
93
|
+
onActiveStylesChange,
|
|
94
|
+
onSegmentsChange,
|
|
95
|
+
segments,
|
|
96
|
+
selection,
|
|
97
|
+
],
|
|
85
98
|
);
|
|
86
99
|
|
|
87
100
|
const setColor = useCallback(
|
package/src/hooks/useRichText.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef } from 'react';
|
|
|
2
2
|
import type {
|
|
3
3
|
StyledSegment,
|
|
4
4
|
FormatStyle,
|
|
5
|
+
OutputFormat,
|
|
5
6
|
SelectionRange,
|
|
6
7
|
RichTextState,
|
|
7
8
|
RichTextActions,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
findPositionInSegments,
|
|
16
17
|
} from '../utils/parser';
|
|
17
18
|
import { getSelectionStyle } from '../utils/formatter';
|
|
19
|
+
import { serializeSegments } from '../utils/serializer';
|
|
18
20
|
import { useSelection } from '../hooks/useSelection';
|
|
19
21
|
import { useFormatting } from '../hooks/useFormatting';
|
|
20
22
|
|
|
@@ -60,6 +62,7 @@ export function useRichText(
|
|
|
60
62
|
selectionRef.current = selection;
|
|
61
63
|
const activeStylesRef = useRef(activeStyles);
|
|
62
64
|
activeStylesRef.current = activeStyles;
|
|
65
|
+
const preserveActiveStylesRef = useRef(false);
|
|
63
66
|
|
|
64
67
|
// ─── Segment Change Handler ──────────────────────────────────────────────
|
|
65
68
|
|
|
@@ -108,8 +111,22 @@ export function useRichText(
|
|
|
108
111
|
|
|
109
112
|
const onSelectionChange = useCallback(
|
|
110
113
|
(newSelection: SelectionRange) => {
|
|
114
|
+
const previousSelection = selectionRef.current;
|
|
111
115
|
handleSelectionChange(newSelection);
|
|
112
116
|
|
|
117
|
+
const shouldPreserveActiveStyles =
|
|
118
|
+
preserveActiveStylesRef.current &&
|
|
119
|
+
previousSelection.start === previousSelection.end &&
|
|
120
|
+
newSelection.start === newSelection.end &&
|
|
121
|
+
newSelection.start >= previousSelection.start &&
|
|
122
|
+
newSelection.start - previousSelection.start <= 1;
|
|
123
|
+
|
|
124
|
+
if (shouldPreserveActiveStyles) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
preserveActiveStylesRef.current = false;
|
|
129
|
+
|
|
113
130
|
// Update active styles based on cursor position
|
|
114
131
|
if (newSelection.start === newSelection.end) {
|
|
115
132
|
const pos = findPositionInSegments(
|
|
@@ -131,6 +148,13 @@ export function useRichText(
|
|
|
131
148
|
return segmentsToPlainText(segmentsRef.current);
|
|
132
149
|
}, []);
|
|
133
150
|
|
|
151
|
+
const getOutput = useCallback(
|
|
152
|
+
(format: OutputFormat = 'markdown'): string => {
|
|
153
|
+
return serializeSegments(segmentsRef.current, format);
|
|
154
|
+
},
|
|
155
|
+
[],
|
|
156
|
+
);
|
|
157
|
+
|
|
134
158
|
const exportJSON = useCallback((): StyledSegment[] => {
|
|
135
159
|
return JSON.parse(JSON.stringify(segmentsRef.current));
|
|
136
160
|
}, []);
|
|
@@ -147,8 +171,69 @@ export function useRichText(
|
|
|
147
171
|
const clear = useCallback(() => {
|
|
148
172
|
updateSegments([createSegment('')]);
|
|
149
173
|
setActiveStyles({ ...EMPTY_FORMAT_STYLE });
|
|
174
|
+
preserveActiveStylesRef.current = false;
|
|
150
175
|
}, [updateSegments]);
|
|
151
176
|
|
|
177
|
+
const toggleFormat = useCallback<RichTextActions['toggleFormat']>(
|
|
178
|
+
(format) => {
|
|
179
|
+
if (selectionRef.current.start === selectionRef.current.end) {
|
|
180
|
+
preserveActiveStylesRef.current = true;
|
|
181
|
+
}
|
|
182
|
+
formatting.toggleFormat(format);
|
|
183
|
+
},
|
|
184
|
+
[formatting],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const setStyleProperty = useCallback<RichTextActions['setStyleProperty']>(
|
|
188
|
+
(key, value) => {
|
|
189
|
+
if (selectionRef.current.start === selectionRef.current.end) {
|
|
190
|
+
preserveActiveStylesRef.current = true;
|
|
191
|
+
}
|
|
192
|
+
formatting.setStyleProperty(key, value);
|
|
193
|
+
},
|
|
194
|
+
[formatting],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const setHeading = useCallback<RichTextActions['setHeading']>(
|
|
198
|
+
(level) => {
|
|
199
|
+
if (selectionRef.current.start === selectionRef.current.end) {
|
|
200
|
+
preserveActiveStylesRef.current = true;
|
|
201
|
+
}
|
|
202
|
+
formatting.setHeading(level);
|
|
203
|
+
},
|
|
204
|
+
[formatting],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const setColor = useCallback<RichTextActions['setColor']>(
|
|
208
|
+
(color) => {
|
|
209
|
+
if (selectionRef.current.start === selectionRef.current.end) {
|
|
210
|
+
preserveActiveStylesRef.current = true;
|
|
211
|
+
}
|
|
212
|
+
formatting.setColor(color);
|
|
213
|
+
},
|
|
214
|
+
[formatting],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const setBackgroundColor = useCallback<RichTextActions['setBackgroundColor']>(
|
|
218
|
+
(color) => {
|
|
219
|
+
if (selectionRef.current.start === selectionRef.current.end) {
|
|
220
|
+
preserveActiveStylesRef.current = true;
|
|
221
|
+
}
|
|
222
|
+
formatting.setBackgroundColor(color);
|
|
223
|
+
},
|
|
224
|
+
[formatting],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const setFontSize = useCallback<RichTextActions['setFontSize']>(
|
|
228
|
+
(size) => {
|
|
229
|
+
if (selectionRef.current.start === selectionRef.current.end) {
|
|
230
|
+
preserveActiveStylesRef.current = true;
|
|
231
|
+
}
|
|
232
|
+
formatting.setFontSize(size);
|
|
233
|
+
},
|
|
234
|
+
[formatting],
|
|
235
|
+
);
|
|
236
|
+
|
|
152
237
|
// ─── Build Return Value ──────────────────────────────────────────────────
|
|
153
238
|
|
|
154
239
|
const state: RichTextState = {
|
|
@@ -158,16 +243,17 @@ export function useRichText(
|
|
|
158
243
|
};
|
|
159
244
|
|
|
160
245
|
const actions: RichTextActions = {
|
|
161
|
-
toggleFormat
|
|
162
|
-
setStyleProperty
|
|
163
|
-
setHeading
|
|
164
|
-
setColor
|
|
165
|
-
setBackgroundColor
|
|
166
|
-
setFontSize
|
|
246
|
+
toggleFormat,
|
|
247
|
+
setStyleProperty,
|
|
248
|
+
setHeading,
|
|
249
|
+
setColor,
|
|
250
|
+
setBackgroundColor,
|
|
251
|
+
setFontSize,
|
|
167
252
|
handleTextChange,
|
|
168
253
|
handleSelectionChange: onSelectionChange,
|
|
169
254
|
isFormatActive: formatting.isFormatActive,
|
|
170
255
|
getSelectionStyle: formatting.currentSelectionStyle,
|
|
256
|
+
getOutput,
|
|
171
257
|
getPlainText,
|
|
172
258
|
exportJSON,
|
|
173
259
|
importJSON,
|
package/src/index.d.ts
CHANGED
|
@@ -11,5 +11,6 @@ export type { RichTextProviderProps } from './context/RichTextContext';
|
|
|
11
11
|
export { createSegment, segmentsToPlainText, getTotalLength, mergeAdjacentSegments, reconcileTextChange, } from './utils/parser';
|
|
12
12
|
export { toggleFormatOnSelection, setStyleOnSelection, setHeadingOnLine, isFormatActiveInSelection, getSelectionStyle, } from './utils/formatter';
|
|
13
13
|
export { formatStyleToTextStyle, segmentToTextStyle, segmentsToTextStyles, } from './utils/styleMapper';
|
|
14
|
+
export { serializeSegments, segmentsToMarkdown, segmentsToHTML, } from './utils/serializer';
|
|
14
15
|
export { DEFAULT_COLORS, DEFAULT_THEME, DEFAULT_TOOLBAR_ITEMS, DEFAULT_BASE_TEXT_STYLE, HEADING_FONT_SIZES, EMPTY_FORMAT_STYLE, } from './constants/defaultStyles';
|
|
15
|
-
export type { FormatType, HeadingLevel, ListType, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, ToolbarButtonRenderProps, ToolbarRenderProps, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types';
|
|
16
|
+
export type { FormatType, HeadingLevel, ListType, OutputFormat, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, ToolbarButtonRenderProps, ToolbarRenderProps, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types';
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,11 @@ export {
|
|
|
37
37
|
segmentToTextStyle,
|
|
38
38
|
segmentsToTextStyles,
|
|
39
39
|
} from './utils/styleMapper';
|
|
40
|
+
export {
|
|
41
|
+
serializeSegments,
|
|
42
|
+
segmentsToMarkdown,
|
|
43
|
+
segmentsToHTML,
|
|
44
|
+
} from './utils/serializer';
|
|
40
45
|
|
|
41
46
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
42
47
|
export {
|
|
@@ -53,6 +58,7 @@ export type {
|
|
|
53
58
|
FormatType,
|
|
54
59
|
HeadingLevel,
|
|
55
60
|
ListType,
|
|
61
|
+
OutputFormat,
|
|
56
62
|
FormatStyle,
|
|
57
63
|
StyledSegment,
|
|
58
64
|
SelectionRange,
|
package/src/types/index.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
|
|
|
11
11
|
* List type for a line/paragraph.
|
|
12
12
|
*/
|
|
13
13
|
export type ListType = 'bullet' | 'ordered' | 'none';
|
|
14
|
+
/**
|
|
15
|
+
* Serialized output formats supported by the editor.
|
|
16
|
+
*/
|
|
17
|
+
export type OutputFormat = 'markdown' | 'html';
|
|
14
18
|
/**
|
|
15
19
|
* Inline formatting styles attached to a text segment.
|
|
16
20
|
*/
|
|
@@ -79,6 +83,8 @@ export interface RichTextActions {
|
|
|
79
83
|
isFormatActive: (format: FormatType) => boolean;
|
|
80
84
|
/** Get the effective shared style at the current cursor/selection. */
|
|
81
85
|
getSelectionStyle: () => FormatStyle;
|
|
86
|
+
/** Serialize the current content as markdown or HTML. */
|
|
87
|
+
getOutput: (format?: OutputFormat) => string;
|
|
82
88
|
/** Get the full plain text content. */
|
|
83
89
|
getPlainText: () => string;
|
|
84
90
|
/** Export the segments as a serializable JSON array. */
|
|
@@ -103,10 +109,16 @@ export interface RichTextTheme {
|
|
|
103
109
|
containerStyle?: ViewStyle;
|
|
104
110
|
/** Style for the TextInput. */
|
|
105
111
|
inputStyle?: TextStyle;
|
|
106
|
-
/** Style for the overlay text container. */
|
|
112
|
+
/** Style for the legacy overlay text container. */
|
|
107
113
|
overlayContainerStyle?: ViewStyle;
|
|
108
114
|
/** Base text style applied to all segments before formatting. */
|
|
109
115
|
baseTextStyle?: TextStyle;
|
|
116
|
+
/** Style for the serialized output container. */
|
|
117
|
+
outputContainerStyle?: ViewStyle;
|
|
118
|
+
/** Label style for the serialized output header. */
|
|
119
|
+
outputLabelStyle?: TextStyle;
|
|
120
|
+
/** Style for the serialized output text. */
|
|
121
|
+
outputTextStyle?: TextStyle;
|
|
110
122
|
/** Style for the toolbar container. */
|
|
111
123
|
toolbarStyle?: ViewStyle;
|
|
112
124
|
/** Style for toolbar buttons. */
|
|
@@ -239,6 +251,12 @@ export interface RichTextInputProps {
|
|
|
239
251
|
toolbarItems?: ToolbarItem[];
|
|
240
252
|
/** Theme configuration. */
|
|
241
253
|
theme?: RichTextTheme;
|
|
254
|
+
/** Whether to show the serialized output preview below the input. */
|
|
255
|
+
showOutputPreview?: boolean;
|
|
256
|
+
/** Format used for the serialized output preview. */
|
|
257
|
+
outputFormat?: OutputFormat;
|
|
258
|
+
/** Callback when the serialized output changes. */
|
|
259
|
+
onChangeOutput?: (output: string, format: OutputFormat) => void;
|
|
242
260
|
/** Whether multiline input is enabled. */
|
|
243
261
|
multiline?: boolean;
|
|
244
262
|
/** Minimum height for the input area. */
|
package/src/types/index.ts
CHANGED
|
@@ -22,6 +22,11 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
|
|
|
22
22
|
*/
|
|
23
23
|
export type ListType = 'bullet' | 'ordered' | 'none';
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Serialized output formats supported by the editor.
|
|
27
|
+
*/
|
|
28
|
+
export type OutputFormat = 'markdown' | 'html';
|
|
29
|
+
|
|
25
30
|
// ─── Style Types ─────────────────────────────────────────────────────────────
|
|
26
31
|
|
|
27
32
|
/**
|
|
@@ -105,6 +110,8 @@ export interface RichTextActions {
|
|
|
105
110
|
isFormatActive: (format: FormatType) => boolean;
|
|
106
111
|
/** Get the effective shared style at the current cursor/selection. */
|
|
107
112
|
getSelectionStyle: () => FormatStyle;
|
|
113
|
+
/** Serialize the current content as markdown or HTML. */
|
|
114
|
+
getOutput: (format?: OutputFormat) => string;
|
|
108
115
|
/** Get the full plain text content. */
|
|
109
116
|
getPlainText: () => string;
|
|
110
117
|
/** Export the segments as a serializable JSON array. */
|
|
@@ -135,10 +142,16 @@ export interface RichTextTheme {
|
|
|
135
142
|
containerStyle?: ViewStyle;
|
|
136
143
|
/** Style for the TextInput. */
|
|
137
144
|
inputStyle?: TextStyle;
|
|
138
|
-
/** Style for the overlay text container. */
|
|
145
|
+
/** Style for the legacy overlay text container. */
|
|
139
146
|
overlayContainerStyle?: ViewStyle;
|
|
140
147
|
/** Base text style applied to all segments before formatting. */
|
|
141
148
|
baseTextStyle?: TextStyle;
|
|
149
|
+
/** Style for the serialized output container. */
|
|
150
|
+
outputContainerStyle?: ViewStyle;
|
|
151
|
+
/** Label style for the serialized output header. */
|
|
152
|
+
outputLabelStyle?: TextStyle;
|
|
153
|
+
/** Style for the serialized output text. */
|
|
154
|
+
outputTextStyle?: TextStyle;
|
|
142
155
|
/** Style for the toolbar container. */
|
|
143
156
|
toolbarStyle?: ViewStyle;
|
|
144
157
|
/** Style for toolbar buttons. */
|
|
@@ -282,6 +295,12 @@ export interface RichTextInputProps {
|
|
|
282
295
|
toolbarItems?: ToolbarItem[];
|
|
283
296
|
/** Theme configuration. */
|
|
284
297
|
theme?: RichTextTheme;
|
|
298
|
+
/** Whether to show the serialized output preview below the input. */
|
|
299
|
+
showOutputPreview?: boolean;
|
|
300
|
+
/** Format used for the serialized output preview. */
|
|
301
|
+
outputFormat?: OutputFormat;
|
|
302
|
+
/** Callback when the serialized output changes. */
|
|
303
|
+
onChangeOutput?: (output: string, format: OutputFormat) => void;
|
|
285
304
|
/** Whether multiline input is enabled. */
|
|
286
305
|
multiline?: boolean;
|
|
287
306
|
/** Minimum height for the input area. */
|
package/src/utils/formatter.ts
CHANGED
|
@@ -8,11 +8,9 @@ import type {
|
|
|
8
8
|
import {
|
|
9
9
|
createSegment,
|
|
10
10
|
findPositionInSegments,
|
|
11
|
-
splitSegment,
|
|
12
11
|
mergeAdjacentSegments,
|
|
13
12
|
segmentsToPlainText,
|
|
14
13
|
} from '../utils/parser';
|
|
15
|
-
import { HEADING_FONT_SIZES } from '../constants/defaultStyles';
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
16
|
* Toggle an inline format (bold, italic, etc.) on the selected range.
|
|
@@ -73,9 +71,7 @@ export function setHeadingOnLine(
|
|
|
73
71
|
const { lineStart, lineEnd } = getLineRange(plainText, selection.start);
|
|
74
72
|
|
|
75
73
|
const headingStyle: Partial<FormatStyle> = {
|
|
76
|
-
heading: level,
|
|
77
|
-
fontSize: HEADING_FONT_SIZES[level],
|
|
78
|
-
bold: level !== 'none' ? true : undefined,
|
|
74
|
+
heading: level === 'none' ? undefined : level,
|
|
79
75
|
};
|
|
80
76
|
|
|
81
77
|
return applyStyleToRange(segments, lineStart, lineEnd, headingStyle);
|