Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .playwright/tests/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const sel = {
setChannelButton: '[data-testid="mention-set-channel-button"]',
startUserButton: '[data-testid="mention-start-user-button"]',
startChannelButton: '[data-testid="mention-start-channel-button"]',
removeInvalidToggle: '[data-testid="mention-remove-invalid-toggle"]',
} as const;

async function gotoMentionTest(page: Page): Promise<void> {
Expand Down Expand Up @@ -313,3 +314,72 @@ test("inserting a mention between text doesn't produce a double space", async ({
'<html><p>example <mention text="Jane" indicator="@" id="1">Jane</mention> test</p></html>'
);
});

// Inserts "Jane" plus the space setMention adds, then moves the caret
// `steps` characters left from the end: 1 is right after the mention,
// 3 is between "Ja" and "ne"
async function insertMentionAndMoveLeft(
page: Page,
steps: number
): Promise<void> {
const editor = mentionEditor(page);
await editor.click();
await editor.pressSequentially('@', { delay: 80 });
await page.locator(sel.setUserButton).click();
await expect
.poll(async () =>
(await htmlOutput(page).textContent())?.includes('<mention')
)
.toBe(true);
await editor.press('End');
for (let i = 0; i < steps; i++) {
await editor.press('ArrowLeft');
}
}

test('typing inside a mention keeps its text by default', async ({ page }) => {
await gotoMentionTest(page);
await insertMentionAndMoveLeft(page, 3);
await mentionEditor(page).press('x');
await expect
.poll(async () => await htmlOutput(page).textContent())
.toEqual('<html><p>Jaxne </p></html>');
});

test('removeInvalidMentionContent: typing inside a mention removes it', async ({
page,
}) => {
await gotoMentionTest(page);
await page.locator(sel.removeInvalidToggle).click();
await insertMentionAndMoveLeft(page, 3);
await mentionEditor(page).press('x');
await expect
.poll(async () => await htmlOutput(page).textContent())
.toEqual('<html><p> </p></html>');
});

test('removeInvalidMentionContent: backspace inside a mention removes it', async ({
page,
}) => {
await gotoMentionTest(page);
await page.locator(sel.removeInvalidToggle).click();
await insertMentionAndMoveLeft(page, 3);
await mentionEditor(page).press('Backspace');
await expect
.poll(async () => await htmlOutput(page).textContent())
.toEqual('<html><p> </p></html>');
});

test('removeInvalidMentionContent: typing right after a mention keeps it', async ({
page,
}) => {
await gotoMentionTest(page);
await page.locator(sel.removeInvalidToggle).click();
await insertMentionAndMoveLeft(page, 1);
await mentionEditor(page).press('x');
await expect
.poll(async () => await htmlOutput(page).textContent())
.toEqual(
'<html><p><mention text="Jane" indicator="@" id="1">Jane</mention>x </p></html>'
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ class EnrichedTextInputViewManager :
view?.parametrizedStyles?.mentionIndicators = indicatorsArray
}

@ReactProp(name = "removeInvalidMentionContent", defaultBoolean = false)
override fun setRemoveInvalidMentionContent(
view: EnrichedTextInputView?,
removeInvalidMentionContent: Boolean,
) {
view?.parametrizedStyles?.removeInvalidMentionContent = removeInvalidMentionContent
}

@ReactProp(name = "htmlStyle")
override fun setHtmlStyle(
view: EnrichedTextInputView?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class ParametrizedStyles(
private var isSettingLinkSpan = false

var mentionIndicators: Array<String> = emptyArray<String>()
var removeInvalidMentionContent = false

fun <T> removeSpansForRange(
spannable: Spannable,
Expand Down Expand Up @@ -94,9 +95,27 @@ class ParametrizedStyles(
endCursorPosition: Int,
) {
afterTextChangedLinks(startCursorPosition, endCursorPosition)
// the deletion re-runs this watcher on the final text with fresh positions
if (removeInvalidMentionContent && removeInvalidMentions(s)) return
detectActiveMention(s, startCursorPosition)
}

private fun removeInvalidMentions(s: Editable): Boolean {
var removed = false
for (span in s.getSpans(0, s.length, EnrichedInputMentionSpan::class.java)) {
val start = s.getSpanStart(span)
val end = s.getSpanEnd(span)
// the nested watcher pass may have removed it already
if (start == -1) continue
if (s.subSequence(start, end).toString() == span.getText()) continue

s.removeSpan(span)
s.delete(start, end)
removed = true
}
return removed
}

// Re-runs in-progress mention detection on a pure caret move (no text change),
fun afterSelectionChangedMentions(
start: Int,
Expand Down
10 changes: 10 additions & 0 deletions apps/example-web/src/testScreens/TestMentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export function TestMentions() {
const [detectedText, setDetectedText] = useState('');
const [detectedIndicator, setDetectedIndicator] = useState('');
const [lastEndEvent, setLastEndEvent] = useState('');
const [removeInvalidMentionContent, setRemoveInvalidMentionContent] =
useState(false);

const preventDefault = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
Expand All @@ -31,6 +33,7 @@ export function TestMentions() {
style={editorStyle}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
mentionIndicators={['@', '#']}
removeInvalidMentionContent={removeInvalidMentionContent}
onStartMention={(indicator) => {
setEventType('start');
setEventIndicator(indicator);
Expand Down Expand Up @@ -112,6 +115,13 @@ export function TestMentions() {
Start mention (channel)
</button>
<button data-testid="mention-blur-target">Blur Editor</button>
<button
data-testid="mention-remove-invalid-toggle"
onMouseDown={preventDefault}
onClick={() => setRemoveInvalidMentionContent((value) => !value)}
>
removeInvalidMentionContent: {String(removeInvalidMentionContent)}
</button>
</div>
);
}
Expand Down
8 changes: 8 additions & 0 deletions apps/example/src/screens/DevScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ interface DevScreenProps {
export function DevScreen({ onSwitch }: DevScreenProps) {
const editor = useEditorState();
const [textNodes, setTextNodes] = useState<Array<string>>([]);
const [removeInvalidMentionContent, setRemoveInvalidMentionContent] =
useState(false);

const handlePushTextNode = async () => {
const currentText = await editor.ref.current?.getHTML();
Expand All @@ -44,6 +46,7 @@ export function DevScreen({ onSwitch }: DevScreenProps) {
<EnrichedTextInput
ref={editor.ref}
mentionIndicators={['@', '#']}
removeInvalidMentionContent={removeInvalidMentionContent}
style={styles.editorInput}
htmlStyle={htmlStyle}
placeholder="Type something here..."
Expand Down Expand Up @@ -96,6 +99,11 @@ export function DevScreen({ onSwitch }: DevScreenProps) {
style={styles.button}
/>
</View>
<Button
title={`removeInvalidMentionContent: ${removeInvalidMentionContent ? 'on' : 'off'}`}
onPress={() => setRemoveInvalidMentionContent((value) => !value)}
style={styles.valueButton}
/>
<Button
title="Test Screen"
onPress={onSwitch}
Expand Down
11 changes: 11 additions & 0 deletions docs/docs/api-reference/enriched-text-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,17 @@ A React ref that lets you call any [ref methods](#ref-methods) on the input.
| ---------------------------------------------- | ------- | ----------------- |
| `RefObject<EnrichedTextInputInstance \| null>` | - | Android, iOS, Web |

### `removeInvalidMentionContent` {#removeinvalidmentioncontent}

If `true`, any change to the text of an inserted mention (typing inside it,
deleting part of it, autocorrect) removes the whole mention. If `false`, the
changed text stays and is no longer a mention. See
[Mentions](/rich-text-formatting/mentions#editing-an-inserted-mention).

| Type | Default | Platforms |
| --------- | ------- | ----------------- |
| `boolean` | `false` | Android, iOS, Web |

### `returnKeyLabel` {#returnkeylabel}

Overrides the return key label with a custom string.
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/rich-text-formatting/mentions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ space, and a second space ends it. That makes `@John Doe` a valid mention.

:::

## Editing an inserted mention

When the text of an inserted mention changes - a character typed inside it, a
part of it deleted, an autocorrect replacement - the text stays and is no
longer a mention. Set
[`removeInvalidMentionContent`](/api-reference/enriched-text-input#removeinvalidmentioncontent)
to remove the whole mention instead.

## Driving mentions with events

This example is deliberately bare-bones - one hard-coded user behind a button.
Expand Down
7 changes: 7 additions & 0 deletions ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,13 @@ - (void)updateProps:(Props::Shared const &)props
[config setLinkRegexConfig:newRegexConfig];
}

// must be set before the default value, which already checks the mentions
if (newViewProps.removeInvalidMentionContent !=
oldViewProps.removeInvalidMentionContent) {
[config setRemoveInvalidMentionContent:newViewProps
.removeInvalidMentionContent];
}

// default value - must be set before placeholder to make sure it correctly
// shows on first mount
if (newViewProps.defaultValue != oldViewProps.defaultValue) {
Expand Down
2 changes: 2 additions & 0 deletions ios/config/EnrichedConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
- (UIFont *)monospacedFont;
- (NSSet<NSNumber *> *)mentionIndicators;
- (void)setMentionIndicators:(NSSet<NSNumber *> *)newValue;
- (BOOL)removeInvalidMentionContent;
- (void)setRemoveInvalidMentionContent:(BOOL)newValue;
- (CGFloat)h1FontSize;
- (void)setH1FontSize:(CGFloat)newValue;
- (BOOL)h1Bold;
Expand Down
10 changes: 10 additions & 0 deletions ios/config/EnrichedConfig.mm
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ @implementation EnrichedConfig {
BOOL _monospacedFontNeedsRecreation;
BOOL _allowFontScaling;
NSSet<NSNumber *> *_mentionIndicators;
BOOL _removeInvalidMentionContent;
CGFloat _h1FontSize;
BOOL _h1Bold;
CGFloat _h2FontSize;
Expand Down Expand Up @@ -83,6 +84,7 @@ - (id)copyWithZone:(NSZone *)zone {
copy->_monospacedFont = [_monospacedFont copy];
copy->_allowFontScaling = _allowFontScaling;
copy->_mentionIndicators = [_mentionIndicators copy];
copy->_removeInvalidMentionContent = _removeInvalidMentionContent;
copy->_h1FontSize = _h1FontSize;
copy->_h1Bold = _h1Bold;
copy->_h2FontSize = _h2FontSize;
Expand Down Expand Up @@ -230,6 +232,14 @@ - (void)setMentionIndicators:(NSSet<NSNumber *> *)newValue {
_mentionIndicators = newValue;
}

- (BOOL)removeInvalidMentionContent {
return _removeInvalidMentionContent;
}

- (void)setRemoveInvalidMentionContent:(BOOL)newValue {
_removeInvalidMentionContent = newValue;
}

- (CGFloat)h1FontSize {
return [self scaledFontSizeForValue:_h1FontSize];
}
Expand Down
39 changes: 37 additions & 2 deletions ios/styles/MentionStyle.mm
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ - (void)handleExistingMentions {

// set of ranges to have their mentions removed - aren't valid anymore
NSMutableSet<NSValue *> *rangesToRemove = [[NSMutableSet alloc] init];
// ranges whose text gets deleted, with removeInvalidMentionContent
NSMutableArray<NSValue *> *rangesToDelete = [[NSMutableArray alloc] init];
MentionParams *lastDeletedParams = nullptr;

for (NSInteger i = 0; i < mentions.count; i++) {
StylePair *mention = mentions[i];
Expand All @@ -295,21 +298,53 @@ - (void)handleExistingMentions {
if (prevRange.location + prevRange.length == currentRange.location) {
[rangesToRemove addObject:[NSValue valueWithRange:prevRange]];
[rangesToRemove addObject:[NSValue valueWithRange:currentRange]];
continue;
// with removeInvalidMentionContent an edited one still has to go
if (![self.host.config removeInvalidMentionContent]) {
continue;
}
}
}

// check for text, any modifications to it makes mention invalid
NSString *existingText =
[self.host.textView.textStorage.string substringWithRange:currentRange];
if (![existingText isEqualToString:currentText]) {
[rangesToRemove addObject:[NSValue valueWithRange:currentRange]];
if ([self.host.config removeInvalidMentionContent]) {
Comment thread
szydlovsky marked this conversation as resolved.
Resolved
// text typed inside a mention doesn't get the attribute, so the
// mention becomes two runs with the same params; union them so the
// typed text goes too
if (lastDeletedParams == mention.styleValue) {
NSRange lastRange = [rangesToDelete.lastObject rangeValue];
rangesToDelete[rangesToDelete.count - 1] =
[NSValue valueWithRange:NSUnionRange(lastRange, currentRange)];
} else {
[rangesToDelete addObject:[NSValue valueWithRange:currentRange]];
}
lastDeletedParams = mention.styleValue;
} else {
[rangesToRemove addObject:[NSValue valueWithRange:currentRange]];
}
}
}

for (NSValue *value in rangesToRemove) {
[self remove:[value rangeValue] withDirtyRange:YES];
}

// mentions are in ascending order, deleting from the end keeps the
// earlier ranges valid; the selection is set without focusing the input,
// as this also runs when the value is set from JS
for (NSValue *value in rangesToDelete.reverseObjectEnumerator) {
[TextInsertionUtils replaceText:@""
at:[value rangeValue]
additionalAttributes:nullptr
host:self.host
withSelection:NO];
}
if (rangesToDelete.count > 0) {
self.host.textView.selectedRange =
NSMakeRange([rangesToDelete.firstObject rangeValue].location, 0);
}
}

// manages active mention range, which in turn emits proper onMention event
Expand Down
2 changes: 2 additions & 0 deletions src/native/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const EnrichedTextInput = ({
autoFocus = false,
editable = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.editable,
mentionIndicators = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.mentionIndicators.slice(),
removeInvalidMentionContent = false,
defaultValue,
placeholder,
placeholderTextColor,
Expand Down Expand Up @@ -374,6 +375,7 @@ export const EnrichedTextInput = ({
<EnrichedTextInputNativeComponent
ref={nativeRef}
mentionIndicators={mentionIndicators}
removeInvalidMentionContent={removeInvalidMentionContent}
editable={editable}
autoFocus={autoFocus}
defaultValue={defaultValue}
Expand Down
1 change: 1 addition & 0 deletions src/spec/EnrichedTextInputNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export interface NativeProps extends ViewProps {
placeholder?: string;
placeholderTextColor?: ColorValue;
mentionIndicators: string[];
removeInvalidMentionContent?: boolean;
cursorColor?: ColorValue;
selectionColor?: ColorValue;
autoCapitalize?: string;
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,13 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
*/
mentionIndicators?: string[];

/**
* If `true`, any change to the text of an inserted mention (typing inside it,
* deleting part of it, autocorrect) removes the whole mention. If `false`, the
* changed text stays and is no longer a mention. Defaults to `false`.
*/
removeInvalidMentionContent?: boolean;

/**
* Initial content rendered when the component mounts.
* Use `ref.setValue()` to update the content imperatively.
Expand Down
Loading