diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index b6fa757c8..005cd1804 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -1,10 +1,10 @@ import './App.css'; +import { EnrichedTextInput } from 'react-native-enriched'; function App() { return (
-
Text input
- +
); } diff --git a/apps/example-web/tsconfig.app.json b/apps/example-web/tsconfig.app.json index 690114886..469857255 100644 --- a/apps/example-web/tsconfig.app.json +++ b/apps/example-web/tsconfig.app.json @@ -3,6 +3,10 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "react-native-enriched": ["../../src/index.web.tsx"] + } }, "include": ["src"] } diff --git a/apps/example-web/vite.config.ts b/apps/example-web/vite.config.ts index 4a5def4c3..9dc8e4dac 100644 --- a/apps/example-web/vite.config.ts +++ b/apps/example-web/vite.config.ts @@ -1,7 +1,16 @@ +import path from 'node:path'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: { + 'react-native-enriched': path.resolve( + __dirname, + '../../src/index.web.tsx' + ), + }, + }, }); diff --git a/package.json b/package.json index 25bd3844f..37a47630d 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,13 @@ "source": "./src/index.tsx", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", + "sideEffects": false, "exports": { ".": { + "react-native": { + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, "types": "./lib/typescript/src/index.d.ts", "default": "./lib/module/index.js" }, @@ -20,6 +25,14 @@ "cpp", "*.podspec", "react-native.config.js", + "!src/web", + "!src/index.web.tsx", + "!lib/module/web", + "!lib/module/index.web.js", + "!lib/module/index.web.js.map", + "!lib/typescript/src/web", + "!lib/typescript/src/index.web.d.ts", + "!lib/typescript/src/index.web.d.ts.map", "!ios/build", "!android/build", "!android/gradle", diff --git a/src/index.tsx b/src/index.tsx index 3cfed7e2f..e5c3fc14e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,6 @@ -export * from './EnrichedTextInput'; +export { EnrichedTextInput } from './native/EnrichedTextInput'; export type { + EnrichedTextInputProps, OnChangeTextEvent, OnChangeHtmlEvent, OnChangeStateEvent, @@ -9,5 +10,11 @@ export type { OnKeyPressEvent, OnPasteImagesEvent, OnSubmitEditing, -} from './spec/EnrichedTextInputNativeComponent'; -export type { HtmlStyle, MentionStyleProperties } from './types'; + HtmlStyle, + MentionStyleProperties, + FocusEvent, + BlurEvent, + EnrichedTextInputInstance, + ContextMenuItem, + OnChangeMentionEvent, +} from './types'; diff --git a/src/index.web.tsx b/src/index.web.tsx new file mode 100644 index 000000000..4dd115ed8 --- /dev/null +++ b/src/index.web.tsx @@ -0,0 +1,20 @@ +export { EnrichedTextInput } from './web/EnrichedTextInput'; +export type { + EnrichedTextInputProps, + OnChangeTextEvent, + OnChangeHtmlEvent, + OnChangeStateEvent, + OnLinkDetected, + OnMentionDetected, + OnChangeSelectionEvent, + OnKeyPressEvent, + OnPasteImagesEvent, + OnSubmitEditing, + HtmlStyle, + MentionStyleProperties, + FocusEvent, + BlurEvent, + EnrichedTextInputInstance, + ContextMenuItem, + OnChangeMentionEvent, +} from './types'; diff --git a/src/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx similarity index 69% rename from src/EnrichedTextInput.tsx rename to src/native/EnrichedTextInput.tsx index 3e8fc9f67..7ff8120b4 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -1,6 +1,5 @@ import { type Component, - type RefObject, useEffect, useImperativeHandle, useMemo, @@ -10,147 +9,28 @@ import { useCallback } from 'react'; import EnrichedTextInputNativeComponent, { Commands, type NativeProps, - type OnChangeHtmlEvent, - type OnChangeSelectionEvent, - type OnChangeStateEvent, - type OnChangeTextEvent, type OnContextMenuItemPressEvent, - type OnLinkDetected, type OnMentionEvent, - type OnMentionDetected, type OnMentionDetectedInternal, type OnRequestHtmlResultEvent, - type OnKeyPressEvent, - type OnPasteImagesEvent, - type OnSubmitEditing, -} from './spec/EnrichedTextInputNativeComponent'; +} from '../spec/EnrichedTextInputNativeComponent'; import type { - ColorValue, HostInstance, MeasureInWindowOnSuccessCallback, MeasureLayoutOnSuccessCallback, MeasureOnSuccessCallback, NativeMethods, NativeSyntheticEvent, - ReturnKeyTypeOptions, - TargetedEvent, - TextStyle, - ViewProps, - ViewStyle, } from 'react-native'; -import { normalizeHtmlStyle } from './utils/normalizeHtmlStyle'; -import { toNativeRegexConfig } from './utils/regexParser'; -import { nullthrows } from './utils/nullthrows'; -import type { HtmlStyle } from './types'; - -export type FocusEvent = NativeSyntheticEvent; -export type BlurEvent = NativeSyntheticEvent; - -export interface EnrichedTextInputInstance extends NativeMethods { - // General commands - focus: () => void; - blur: () => void; - setValue: (value: string) => void; - setSelection: (start: number, end: number) => void; - getHTML: () => Promise; - - // Text formatting commands - toggleBold: () => void; - toggleItalic: () => void; - toggleUnderline: () => void; - toggleStrikeThrough: () => void; - toggleInlineCode: () => void; - toggleH1: () => void; - toggleH2: () => void; - toggleH3: () => void; - toggleH4: () => void; - toggleH5: () => void; - toggleH6: () => void; - toggleCodeBlock: () => void; - toggleBlockQuote: () => void; - toggleOrderedList: () => void; - toggleUnorderedList: () => void; - toggleCheckboxList: (checked: boolean) => void; - setLink: (start: number, end: number, text: string, url: string) => void; - removeLink: (start: number, end: number) => void; - setImage: (src: string, width: number, height: number) => void; - startMention: (indicator: string) => void; - setMention: ( - indicator: string, - text: string, - attributes?: Record - ) => void; -} - -export interface ContextMenuItem { - text: string; - onPress: ({ - text, - selection, - styleState, - }: { - text: string; - selection: { start: number; end: number }; - styleState: OnChangeStateEvent; - }) => void; - visible?: boolean; -} - -export interface OnChangeMentionEvent { - indicator: string; - text: string; -} - -export interface EnrichedTextInputProps extends Omit { - ref?: RefObject; - autoFocus?: boolean; - editable?: boolean; - mentionIndicators?: string[]; - defaultValue?: string; - placeholder?: string; - placeholderTextColor?: ColorValue; - cursorColor?: ColorValue; - selectionColor?: ColorValue; - autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; - htmlStyle?: HtmlStyle; - style?: ViewStyle | TextStyle; - scrollEnabled?: boolean; - linkRegex?: RegExp | null; - returnKeyType?: ReturnKeyTypeOptions; - returnKeyLabel?: string; - submitBehavior?: 'submit' | 'blurAndSubmit' | 'newline'; - onFocus?: (e: FocusEvent) => void; - onBlur?: (e: BlurEvent) => void; - onChangeText?: (e: NativeSyntheticEvent) => void; - onChangeHtml?: (e: NativeSyntheticEvent) => void; - onChangeState?: (e: NativeSyntheticEvent) => void; - onLinkDetected?: (e: OnLinkDetected) => void; - onMentionDetected?: (e: OnMentionDetected) => void; - onStartMention?: (indicator: string) => void; - onChangeMention?: (e: OnChangeMentionEvent) => void; - onEndMention?: (indicator: string) => void; - onChangeSelection?: (e: NativeSyntheticEvent) => void; - onKeyPress?: (e: NativeSyntheticEvent) => void; - onSubmitEditing?: (e: NativeSyntheticEvent) => void; - onPasteImages?: (e: NativeSyntheticEvent) => void; - contextMenuItems?: ContextMenuItem[]; - /** - * If true, Android will use experimental synchronous events. - * This will prevent from input flickering when updating component size. - * However, this is an experimental feature, which has not been thoroughly tested. - * We may decide to enable it by default in a future release. - * Disabled by default. - */ - androidExperimentalSynchronousEvents?: boolean; - /** - * If true, external HTML (e.g. from Google Docs, Word, web pages) will be - * normalized through the HTML normalizer before being applied. - * This converts arbitrary HTML into the canonical tag subset that the enriched - * parser understands. - * Disabled by default. - */ - useHtmlNormalizer?: boolean; -} +import { normalizeHtmlStyle } from '../utils/normalizeHtmlStyle'; +import { toNativeRegexConfig } from '../utils/regexParser'; +import { nullthrows } from '../utils/nullthrows'; +import type { + ContextMenuItem, + EnrichedTextInputProps, + OnLinkDetected, + OnMentionDetected, +} from '../types'; const warnMentionIndicators = (indicator: string) => { console.warn( @@ -416,7 +296,11 @@ export const EnrichedTextInput = ({ ) => { const { text, indicator, payload } = e.nativeEvent; const attributes = JSON.parse(payload) as Record; - onMentionDetected?.({ text, indicator, attributes }); + onMentionDetected?.({ + text, + indicator, + attributes, + } satisfies OnMentionDetected); }; const handleRequestHtmlResult = ( diff --git a/src/types.ts b/src/types.ts index b7f9591bb..aeee6e931 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,14 @@ -import type { ColorValue, TextStyle } from 'react-native'; +import type { RefObject } from 'react'; +import type { + ColorValue, + NativeMethods, + NativeSyntheticEvent, + ReturnKeyTypeOptions, + TargetedEvent, + TextStyle, + ViewProps, + ViewStyle, +} from 'react-native'; interface HeadingStyle { fontSize?: number; @@ -57,3 +67,258 @@ export interface HtmlStyle { boxColor?: ColorValue; }; } + +// Event types + +export interface OnChangeTextEvent { + value: string; +} + +export interface OnChangeHtmlEvent { + value: string; +} + +export interface OnChangeStateEvent { + bold: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + italic: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + underline: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + strikeThrough: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + inlineCode: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + h1: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + h2: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + h3: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + h4: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + h5: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + h6: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + codeBlock: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + blockQuote: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + orderedList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + unorderedList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + link: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + image: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + mention: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + checkboxList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; +} + +export interface OnLinkDetected { + text: string; + url: string; + start: number; + end: number; +} + +export interface OnMentionDetected { + text: string; + indicator: string; + attributes: Record; +} + +export interface OnChangeSelectionEvent { + start: number; + end: number; + text: string; +} + +export interface OnKeyPressEvent { + key: string; +} + +export interface OnPasteImagesEvent { + images: { + uri: string; + type: string; + width: number; + height: number; + }[]; +} + +export interface OnSubmitEditing { + text: string; +} + +// Component types + +export type FocusEvent = NativeSyntheticEvent; +export type BlurEvent = NativeSyntheticEvent; + +export interface EnrichedTextInputInstance extends NativeMethods { + // General commands + focus: () => void; + blur: () => void; + setValue: (value: string) => void; + setSelection: (start: number, end: number) => void; + getHTML: () => Promise; + + // Text formatting commands + toggleBold: () => void; + toggleItalic: () => void; + toggleUnderline: () => void; + toggleStrikeThrough: () => void; + toggleInlineCode: () => void; + toggleH1: () => void; + toggleH2: () => void; + toggleH3: () => void; + toggleH4: () => void; + toggleH5: () => void; + toggleH6: () => void; + toggleCodeBlock: () => void; + toggleBlockQuote: () => void; + toggleOrderedList: () => void; + toggleUnorderedList: () => void; + toggleCheckboxList: (checked: boolean) => void; + setLink: (start: number, end: number, text: string, url: string) => void; + removeLink: (start: number, end: number) => void; + setImage: (src: string, width: number, height: number) => void; + startMention: (indicator: string) => void; + setMention: ( + indicator: string, + text: string, + attributes?: Record + ) => void; +} + +export interface ContextMenuItem { + text: string; + onPress: ({ + text, + selection, + styleState, + }: { + text: string; + selection: { start: number; end: number }; + styleState: OnChangeStateEvent; + }) => void; + visible?: boolean; +} + +export interface OnChangeMentionEvent { + indicator: string; + text: string; +} + +export interface EnrichedTextInputProps extends Omit { + ref?: RefObject; + autoFocus?: boolean; + editable?: boolean; + mentionIndicators?: string[]; + defaultValue?: string; + placeholder?: string; + placeholderTextColor?: ColorValue; + cursorColor?: ColorValue; + selectionColor?: ColorValue; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + htmlStyle?: HtmlStyle; + style?: ViewStyle | TextStyle; + scrollEnabled?: boolean; + linkRegex?: RegExp | null; + returnKeyType?: ReturnKeyTypeOptions; + returnKeyLabel?: string; + submitBehavior?: 'submit' | 'blurAndSubmit' | 'newline'; + onFocus?: (e: FocusEvent) => void; + onBlur?: (e: BlurEvent) => void; + onChangeText?: (e: NativeSyntheticEvent) => void; + onChangeHtml?: (e: NativeSyntheticEvent) => void; + onChangeState?: (e: NativeSyntheticEvent) => void; + onLinkDetected?: (e: OnLinkDetected) => void; + onMentionDetected?: (e: OnMentionDetected) => void; + onStartMention?: (indicator: string) => void; + onChangeMention?: (e: OnChangeMentionEvent) => void; + onEndMention?: (indicator: string) => void; + onChangeSelection?: (e: NativeSyntheticEvent) => void; + onKeyPress?: (e: NativeSyntheticEvent) => void; + onSubmitEditing?: (e: NativeSyntheticEvent) => void; + onPasteImages?: (e: NativeSyntheticEvent) => void; + contextMenuItems?: ContextMenuItem[]; + /** + * If true, Android will use experimental synchronous events. + * This will prevent from input flickering when updating component size. + * However, this is an experimental feature, which has not been thoroughly tested. + * We may decide to enable it by default in a future release. + * Disabled by default. + */ + androidExperimentalSynchronousEvents?: boolean; + /** + * If true, external HTML (e.g. from Google Docs, Word, web pages) will be + * normalized through the HTML normalizer before being applied. + * This converts arbitrary HTML into the canonical tag subset that the enriched + * parser understands. + * Disabled by default. + */ + useHtmlNormalizer?: boolean; +} diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx new file mode 100644 index 000000000..668ab7e4f --- /dev/null +++ b/src/web/EnrichedTextInput.tsx @@ -0,0 +1,48 @@ +import { useImperativeHandle } from 'react'; +import type { + EnrichedTextInputInstance, + EnrichedTextInputProps, +} from '../types'; + +export const EnrichedTextInput = ({ + ref, + defaultValue, +}: EnrichedTextInputProps) => { + useImperativeHandle( + ref, + (): EnrichedTextInputInstance => ({ + focus: () => {}, + blur: () => {}, + setValue: () => {}, + setSelection: () => {}, + getHTML: () => Promise.resolve(''), + toggleBold: () => {}, + toggleItalic: () => {}, + toggleUnderline: () => {}, + toggleStrikeThrough: () => {}, + toggleInlineCode: () => {}, + toggleH1: () => {}, + toggleH2: () => {}, + toggleH3: () => {}, + toggleH4: () => {}, + toggleH5: () => {}, + toggleH6: () => {}, + toggleCodeBlock: () => {}, + toggleBlockQuote: () => {}, + toggleOrderedList: () => {}, + toggleUnorderedList: () => {}, + toggleCheckboxList: () => {}, + setLink: () => {}, + removeLink: () => {}, + setImage: () => {}, + startMention: () => {}, + setMention: () => {}, + measure: () => {}, + measureInWindow: () => {}, + measureLayout: () => {}, + setNativeProps: () => {}, + }) + ); + + return