diff --git a/docs/docs/guides/chat-input-with-images.md b/docs/docs/guides/chat-input-with-images.md deleted file mode 100644 index e5cc04798..000000000 --- a/docs/docs/guides/chat-input-with-images.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Chat input with images - - diff --git a/docs/docs/guides/custom-context-menu.md b/docs/docs/guides/custom-context-menu.md index fabbe5fa7..eed8fc72a 100644 --- a/docs/docs/guides/custom-context-menu.md +++ b/docs/docs/guides/custom-context-menu.md @@ -4,4 +4,135 @@ sidebar_position: 4 # Custom context menu - +The `contextMenuItems` prop lets you add your own actions to the native +text-selection menu - the popover that e.g. shows **Copy / Paste / Cut** when the +user long-presses selected text. + +:::info + +This is a **native-only** feature (iOS and Android) - which +is why this page has no live preview. To see it in action, run the snippet below in the +[example app](https://github.com/software-mansion/react-native-enriched-html/tree/main/apps/example). + +::: + +## The shape of an item + +```ts +interface ContextMenuItem { + text: string; // the label shown in the menu + visible?: boolean; // whether to show it (defaults to true) + onPress: (args: { + text: string; // the currently selected text + selection: { start: number; end: number }; // its range + styleState: OnChangeStateEvent; // active styles + }) => void; +} +``` + +Every `onPress` receives the same three-field payload, resolved at the moment +the item is tapped: + +- **`text`** - the selected text. +- **`selection`** - the `start` and `end` offsets of the selection. +- **`styleState`** - the latest style state, the same object you get from + `onChangeState`. + +`visible` is read when the menu opens, so you can drive it from state to show an +item only in the right context. + +## Example + +This editor adds three items. The first two read the selection payload and run an +editor command. The third links the selection, so it's only shown via `visible` +when there's actually a ranged selection to link: + +```tsx +import { EnrichedTextInput } from 'react-native-enriched-html'; +import type { + ContextMenuItem, + EnrichedTextInputInstance, + OnChangeSelectionEvent, +} from 'react-native-enriched-html'; +import { useMemo, useRef, useState } from 'react'; +import { View, StyleSheet, Alert } from 'react-native'; + +export default function App() { + const ref = useRef(null); + const [selection, setSelection] = useState( + null + ); + + const hasRangedSelection = !!selection && selection.start !== selection.end; + + const contextMenuItems: ContextMenuItem[] = useMemo( + () => [ + { + // `text` and `selection` describe what the user long-pressed. + text: 'Show selection', + onPress: ({ text, selection: range }) => { + Alert.alert( + 'Selection', + `"${text}" at [${range.start}, ${range.end}]` + ); + }, + }, + { + // Menu items can call any editor command through the ref. + text: 'Bold', + onPress: () => { + ref.current?.toggleBold(); + }, + }, + { + // Only useful with a ranged selection, so hide it otherwise; when + // shown, `selection` lets you target the exact range you were given. + text: 'Link to Software Mansion', + visible: hasRangedSelection, + onPress: ({ text, selection: range }) => { + ref.current?.setLink( + range.start, + range.end, + text, + 'https://swmansion.com' + ); + }, + }, + ], + [hasRangedSelection] + ); + + return ( + + setSelection(e.nativeEvent)} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { gap: 12 }, + input: { + fontSize: 18, + color: '#232736', + padding: 12, + borderRadius: 12, + minHeight: 96, + backgroundColor: '#eef0ff', + }, +}); +``` + +:::note + +Item placement differs per platform. On **iOS** your items appear in array +order, before the system items (Copy/Paste/Cut). On **Android** there is no +guaranteed order, and depending on the device manufacturer your items may be +tucked into an overflow submenu. + +::: diff --git a/docs/docs/guides/emojis.md b/docs/docs/guides/emojis.md deleted file mode 100644 index b8fc83f9a..000000000 --- a/docs/docs/guides/emojis.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Emojis - - diff --git a/docs/docs/guides/emojis.mdx b/docs/docs/guides/emojis.mdx new file mode 100644 index 000000000..f19cb922b --- /dev/null +++ b/docs/docs/guides/emojis.mdx @@ -0,0 +1,85 @@ +--- +sidebar_position: 3 +--- + +import InteractiveExample from '@site/src/components/InteractiveExample'; +import EmojiEditor from '@site/src/examples/EmojiEditor'; +import EmojiEditorSrc from '!!raw-loader!@site/src/examples/EmojiEditor'; + +# Emojis + +Mentions are powerful enough that you can build much more with them. An emoji picker is just a mention where the display text does not match the query. We'll use `:` as the indicator: the user types `:smile`, picks from a list, and the shortcode is +replaced with the emoji itself. If the mention events are new to you, start with +[Mentions](/rich-text-formatting/mentions) and the +[User and channel mentions](/guides/user-and-channel-mentions) guide; this page reuses the +same flow. + +## A shortcode table + +Map each shortcode to the glyph it inserts: + +```tsx +const EMOJIS = [ + { shortcode: 'smile', char: '😄' }, + { shortcode: 'heart', char: '❤️' }, + { shortcode: 'fire', char: '🔥' }, + { shortcode: 'rocket', char: '🚀' }, +]; +``` + +## Wiring it up + +Register `:` as the only indicator and filter the table with the text typed +after it. Because people habitually close the shortcode (`:smile:`), strip a +trailing colon before matching: + +```tsx + setOpen(true)} + onChangeMention={({ text }) => setQuery(text)} + onEndMention={() => setOpen(false)} + // ... +/>; + +const q = query.replace(/:$/, '').toLowerCase(); +const suggestions = EMOJIS.filter(e => e.shortcode.startsWith(q)); +``` + +When the user picks, `setMention` inserts the glyph as the mention's display +text. The `data-shortcode` makes a handy attribute if you ever need to reconstruct it: + +```tsx +const pick = (emoji) => { + ref.current?.setMention(':', emoji.char, { + 'data-shortcode': emoji.shortcode, + }); +}; +``` + +Since the emoji glyph is the whole mention, drop the usual highlight so it +reads as plain text: + +```tsx +const htmlStyle = { + mention: { + ':': { + color: '#232736', + backgroundColor: 'transparent', + textDecorationLine: 'none', + }, + }, +}; +``` + +## Try it out + +Type `:` followed by a name - `:fire`, `:heart` - then tap a suggestion. The shortcode is replaced with a single emoji that you can place anywhere in the text. + + + +:::info + +If you want to see the whole code used to build this example, you can find it by switching the tab from `Preview` to `Code`. + +::: diff --git a/docs/docs/guides/mention-only-input.md b/docs/docs/guides/mention-only-input.md deleted file mode 100644 index 85b2e7572..000000000 --- a/docs/docs/guides/mention-only-input.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Mention-only input - - diff --git a/docs/docs/guides/user-and-channel-mentions.mdx b/docs/docs/guides/user-and-channel-mentions.mdx new file mode 100644 index 000000000..3dea1c66f --- /dev/null +++ b/docs/docs/guides/user-and-channel-mentions.mdx @@ -0,0 +1,112 @@ +--- +sidebar_position: 2 +--- + +import InteractiveExample from '@site/src/components/InteractiveExample'; +import MentionOnlyEditor from '@site/src/examples/MentionOnlyEditor'; +import MentionOnlyEditorSrc from '!!raw-loader!@site/src/examples/MentionOnlyEditor'; + +# User and channel mentions + +This guide wires the mention events into a complete picker - the flow behind a +chat composer where typing `@` suggests people and `#` suggests channels. If you +haven't met the mention API yet, read +[Mentions](/rich-text-formatting/mentions) first; here we move quickly and +assume the events and `setMention` are familiar. + +## The data behind a mention + +Mentions are particularly useful if they point at something. Keep two lists around - one +for users, one for channels - each item carrying an `id` you can attach +to the finished mention: + +```tsx +const USERS = [ + { id: 'u1', name: 'John Doe' }, + { id: 'u2', name: 'Jane Smith' }, + { id: 'u3', name: 'Alice Johnson' }, + { id: 'u4', name: 'Bob Brown' }, +]; + +const CHANNELS = [ + { id: 'c1', name: 'general' }, + { id: 'c2', name: 'engineering' }, + { id: 'c3', name: 'random' }, + { id: 'c4', name: 'announcements' }, +]; +``` + +## Wiring the picker + +Now let's register both indicators and the events callbacks: + +```tsx + +``` + +`onStartMention` hands you the indicator, so you know whether to show people or +channels. `onChangeMention` hands you the `text` typed so far - filter your list +with it. `onEndMention` fires when the cursor leaves the mention, so you dismiss +the list. + +When the user taps a suggestion, finish the mention with `setMention`. Pass the +same indicator that started it, the display text, and +the item's data as attributes: + +```tsx +const pick = (item) => { + ref.current?.setMention(indicator, `${indicator}${item.name}`, { + id: item.id, + }); +}; +``` + +Now let's give each indicator its own look through +`htmlStyle.mention`: + +```tsx +const htmlStyle = { + mention: { + '@': { color: '#2b7a4b', backgroundColor: '#d8f3e3' }, + '#': { color: '#2b5f9e', backgroundColor: '#d8e6f9' }, + }, +}; +``` + +## Try it out + +Type `@` to filter people or `#` to filter channels, keep typing to narrow the +list, then tap a suggestion. + + + +:::info + +If you want to see the whole code used to build this example, you can find it by switching the tab from `Preview` to `Code`. + +::: + +:::tip + +A mention is only active while the editor is focused - if it blurs, +`onEndMention` fires and `setMention` becomes a no-op. On native, tapping a +suggestion never steals focus, so it just works. On web it does, so the rows +call `preventDefault` on `mousedown` to keep the editor focused. The example +wraps that in a small `keepEditorFocused` helper (see the Code tab). + +::: + +:::note + +The attributes you pass to `setMention` (here `{ id }`) ride along in the HTML +and survive a round-trip through `getHTML` / `setValue`. Prefix custom keys with +`data-` if they need to outlive a sanitizer - see the note in +[Mentions](/rich-text-formatting/mentions). + +::: diff --git a/docs/docs/rich-text-formatting/mentions.mdx b/docs/docs/rich-text-formatting/mentions.mdx index 2df1fd0b4..58779e38c 100644 --- a/docs/docs/rich-text-formatting/mentions.mdx +++ b/docs/docs/rich-text-formatting/mentions.mdx @@ -87,7 +87,7 @@ that: You'd open the list on `onStartMention`, filter it on `onChangeMention`, and call `setMention` when the user picks someone. This example doesn't wire up any of that; the full picker flow is covered end-to-end in the -[Mention-only input](/guides/mention-only-input) guide. +[user and channel mentions](/guides/user-and-channel-mentions) guide. :::tip diff --git a/docs/src/examples/EmojiEditor.tsx b/docs/src/examples/EmojiEditor.tsx new file mode 100644 index 000000000..12a3ab621 --- /dev/null +++ b/docs/src/examples/EmojiEditor.tsx @@ -0,0 +1,133 @@ +import { EnrichedTextInput } from 'react-native-enriched-html'; +import type { + EnrichedTextInputInstance, + HtmlStyle, + OnChangeMentionEvent, +} from 'react-native-enriched-html'; +import { useMemo, useRef, useState } from 'react'; +import { View, StyleSheet, Pressable, Text, Platform } from 'react-native'; + +type Emoji = { shortcode: string; char: string }; + +// On web, a tap outside the editor blurs it and ends the active mention before +// `onPress` fires - so `setMention` would do nothing. Preventing the default +// mousedown keeps focus in the editor. It's a no-op on native, where tapping a +// Pressable never steals focus. +const keepEditorFocused: any = + Platform.OS === 'web' + ? { onMouseDown: (e: { preventDefault(): void }) => e.preventDefault() } + : null; + +const EMOJIS: Emoji[] = [ + { shortcode: 'smile', char: '😄' }, + { shortcode: 'heart', char: '❤️' }, + { shortcode: 'fire', char: '🔥' }, + { shortcode: 'rocket', char: '🚀' }, +]; + +// The emoji glyph is the whole mention, so keep it visually plain. +const htmlStyle: HtmlStyle = { + mention: { + ':': { + color: '#232736', + backgroundColor: 'transparent', + textDecorationLine: 'none', + }, + }, +}; + +export default function App() { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + + const suggestions = useMemo(() => { + if (!open) return []; + // A trailing ":" (as in ":smile:") is part of the query - drop it. + const q = query.replace(/:$/, '').toLowerCase(); + return EMOJIS.filter(emoji => emoji.shortcode.startsWith(q)); + }, [open, query]); + + const closePicker = () => { + setOpen(false); + setQuery(''); + }; + + const pick = (emoji: Emoji) => { + // Replaces the ":smile" the user typed with the emoji glyph. + ref.current?.setMention(':', emoji.char, { + 'data-shortcode': emoji.shortcode, + }); + closePicker(); + }; + + return ( + + { + setOpen(true); + setQuery(''); + }} + onChangeMention={({ text }: OnChangeMentionEvent) => { + setOpen(true); + setQuery(text); + }} + onEndMention={closePicker} + /> + {suggestions.length > 0 && ( + + {suggestions.map(emoji => ( + pick(emoji)}> + {emoji.char} + :{emoji.shortcode}: + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { gap: 8 }, + input: { + fontSize: 18, + color: '#232736', + padding: 12, + borderRadius: 12, + minHeight: 96, + backgroundColor: '#eef0ff', + }, + picker: { + borderWidth: 1, + borderColor: '#dfe3f5', + borderRadius: 12, + backgroundColor: '#ffffff', + overflow: 'hidden', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 8, + paddingHorizontal: 12, + }, + emoji: { + fontSize: 20, + width: 28, + textAlign: 'center', + }, + shortcode: { + fontSize: 16, + color: '#5b6bb0', + }, +}); diff --git a/docs/src/examples/MentionOnlyEditor.tsx b/docs/src/examples/MentionOnlyEditor.tsx new file mode 100644 index 000000000..466c948fe --- /dev/null +++ b/docs/src/examples/MentionOnlyEditor.tsx @@ -0,0 +1,158 @@ +import { EnrichedTextInput } from 'react-native-enriched-html'; +import type { + EnrichedTextInputInstance, + HtmlStyle, + OnChangeMentionEvent, +} from 'react-native-enriched-html'; +import { useMemo, useRef, useState } from 'react'; +import { View, StyleSheet, Pressable, Text, Platform } from 'react-native'; + +type Suggestion = { id: string; name: string }; + +// On web, a tap outside the editor blurs it and ends the active mention before +// `onPress` fires - so `setMention` would do nothing. Preventing the default +// mousedown keeps focus in the editor. It's a no-op on native, where tapping a +// Pressable never steals focus. +const keepEditorFocused: any = + Platform.OS === 'web' + ? { onMouseDown: (e: { preventDefault(): void }) => e.preventDefault() } + : null; + +const USERS: Suggestion[] = [ + { id: 'u1', name: 'John Doe' }, + { id: 'u2', name: 'Jane Smith' }, + { id: 'u3', name: 'Alice Johnson' }, + { id: 'u4', name: 'Bob Brown' }, +]; + +const CHANNELS: Suggestion[] = [ + { id: 'c1', name: 'general' }, + { id: 'c2', name: 'engineering' }, + { id: 'c3', name: 'random' }, + { id: 'c4', name: 'announcements' }, +]; + +// Each mention kind is styled by its indicator. +const htmlStyle: HtmlStyle = { + mention: { + '@': { + color: '#2b7a4b', + backgroundColor: '#d8f3e3', + textDecorationLine: 'none', + }, + '#': { + color: '#2b5f9e', + backgroundColor: '#d8e6f9', + textDecorationLine: 'none', + }, + }, +}; + +export default function App() { + const ref = useRef(null); + // The indicator of the mention being edited ('@' | '#'), or null when idle. + const [indicator, setIndicator] = useState(null); + const [query, setQuery] = useState(''); + + const suggestions = useMemo(() => { + if (indicator === null) return []; + const source = indicator === '#' ? CHANNELS : USERS; + const q = query.toLowerCase(); + return source.filter(item => item.name.toLowerCase().startsWith(q)); + }, [indicator, query]); + + const openPicker = (nextIndicator: string) => { + setIndicator(nextIndicator); + setQuery(''); + }; + + const closePicker = () => { + setIndicator(null); + setQuery(''); + }; + + const pick = (item: Suggestion) => { + if (indicator === null) return; + // Replaces the "@jo" / "#gen" the user typed with a finished mention. + ref.current?.setMention(indicator, `${indicator}${item.name}`, { + id: item.id, + }); + closePicker(); + }; + + return ( + + { + setIndicator(ind); + setQuery(text); + }} + onEndMention={closePicker} + /> + {suggestions.length > 0 && ( + + {suggestions.map(item => ( + pick(item)}> + + {indicator} + + {item.name} + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { gap: 8 }, + input: { + fontSize: 18, + color: '#232736', + padding: 12, + borderRadius: 12, + minHeight: 96, + backgroundColor: '#eef0ff', + }, + picker: { + borderWidth: 1, + borderColor: '#dfe3f5', + borderRadius: 12, + backgroundColor: '#ffffff', + overflow: 'hidden', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 8, + paddingHorizontal: 12, + }, + badge: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#eef0ff', + }, + badgeText: { + color: '#5b6bb0', + fontWeight: '600', + }, + rowText: { + fontSize: 16, + color: '#232736', + }, +});