Skip to content

Commit 7ce3c4d

Browse files
authored
feat(web): pressable links in the input component (#795)
# Summary Closes #790 - implemented `onLinkPress` prop for the `EnrichedTextInput` component on Web. If not provided, the behavior doesn't change. - as now both components use `a.pressColor` in `htmlStyle`, code regarding the previously `EnrichedText`-only `a.pressColor` has been refactored. - also I've noticed that in `EnrichedText` when `onLinkPress` was not provided, you could still 'click' the link - `pressColor` was applied and cursor was changed to `pointer`. Fixed. All e2e and jest web tests pass. ## Test Plan The onPressLink event is wired up in the example app, you can see its effect in the console logs. Play around with it, see if the the link's colors are correctly changing when pressing. Then you can remove the `onLinkPress` prop from both the `EnrichedTextInput` and `EnrichedText` component and see that the links will not react on clicks anymore. ## Screenshots / Videos With `onLinkPress` defined: https://github.com/user-attachments/assets/0a3a9e32-701b-474f-86dc-2b855e662850 With `onLinkPress` not defined: https://github.com/user-attachments/assets/834efa57-060d-4f16-b918-454982fc8d85 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [x] Required E2E tests have been added (if applicable)
1 parent f0fea12 commit 7ce3c4d

13 files changed

Lines changed: 181 additions & 34 deletions

File tree

.playwright/tests/links.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const sel = {
3333
'[data-testid="test-links-apply-setlink-from-selection-button"]',
3434
selectionPayload: '[data-testid="test-links-selection-payload"]',
3535
onLinkDetectedPayload: '[data-testid="on-link-detected-payload"]',
36+
onLinkPressEnabled: '[data-testid="test-links-onlinkpress-enabled"]',
37+
onLinkPressPayload: '[data-testid="on-link-press-payload"]',
3638
editorInner: '[data-testid="test-links-editor"] .eti-editor',
3739
editorScreenshot: '[data-testid="test-links-editor"]',
3840
linkRegexMode: '[data-testid="test-links-link-regex-mode"]',
@@ -63,6 +65,10 @@ async function getOnLinkDetectedPayload(page: Page): Promise<string> {
6365
return (await page.locator(sel.onLinkDetectedPayload).textContent()) ?? '';
6466
}
6567

68+
async function getOnLinkPressPayload(page: Page): Promise<string> {
69+
return (await page.locator(sel.onLinkPressPayload).textContent()) ?? '';
70+
}
71+
6672
test('links display visual regression', async ({ page }) => {
6773
await gotoVisualRegression(page);
6874
const html = [
@@ -418,6 +424,39 @@ test.describe('test-links onLinkDetected', () => {
418424
});
419425
});
420426

427+
test.describe('test-links onLinkPress', () => {
428+
test('clicking a link does nothing when onLinkPress is not provided', async ({
429+
page,
430+
}) => {
431+
await gotoTestLinks(page);
432+
await setTestLinksEditorHtml(
433+
page,
434+
'<html><p><a href="https://example.com">Example</a></p></html>'
435+
);
436+
437+
await page.locator(sel.editorInner).locator('a').click();
438+
439+
await expect(page.locator(sel.onLinkPressPayload)).toHaveText('null');
440+
});
441+
442+
test('clicking a link fires onLinkPress with the url when provided', async ({
443+
page,
444+
}) => {
445+
await gotoTestLinks(page);
446+
await page.check(sel.onLinkPressEnabled);
447+
await setTestLinksEditorHtml(
448+
page,
449+
'<html><p><a href="https://example.com">Example</a></p></html>'
450+
);
451+
452+
await page.locator(sel.editorInner).locator('a').click();
453+
454+
await expect
455+
.poll(async () => getOnLinkPressPayload(page))
456+
.toBe(JSON.stringify({ url: 'https://example.com' }));
457+
});
458+
});
459+
421460
test.describe('test-links autolink', () => {
422461
async function resetEditorAndSetLinkRegexMode(
423462
page: Page,

apps/example-web/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type OnSubmitEditing,
1515
type OnChangeMentionEvent,
1616
type OnMentionDetected,
17+
type OnLinkPressEvent,
1718
} from 'react-native-enriched-html';
1819
import { WEB_DEFAULT_HTML_STYLE } from './defaultHtmlStyle';
1920
import type { NativeSyntheticEvent } from 'react-native';
@@ -224,6 +225,10 @@ function App() {
224225
setCurrentLink(e);
225226
};
226227

228+
const handleLinkPress = (e: OnLinkPressEvent) => {
229+
console.log('[EnrichedTextInput] onLinkPress event', e);
230+
};
231+
227232
const handlePasteImages = (e: NativeSyntheticEvent<OnPasteImagesEvent>) => {
228233
const DEFAULT_W = 80;
229234
const DEFAULT_H = 80;
@@ -275,6 +280,7 @@ function App() {
275280
onChangeState={handleChangeState}
276281
onSubmitEditing={handleSubmitEditing}
277282
onLinkDetected={handleOnLinkDetected}
283+
onLinkPress={handleLinkPress}
278284
onPasteImages={handlePasteImages}
279285
onStartMention={handleStartMention}
280286
onChangeMention={handleChangeMention}

apps/example-web/src/defaultHtmlStyle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const WEB_DEFAULT_HTML_STYLE: HtmlStyle = {
4343
a: {
4444
color: 'green',
4545
textDecorationLine: 'underline',
46+
pressColor: 'darkblue',
4647
},
4748
ol: {
4849
gapWidth: 16,

apps/example-web/src/testScreens/TestLinks.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type EnrichedTextInputInstance,
66
type OnChangeSelectionEvent,
77
type OnLinkDetected,
8+
type OnLinkPressEvent,
89
} from 'react-native-enriched-html';
910
import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle';
1011

@@ -37,6 +38,9 @@ export function TestLinks() {
3738
useState<OnLinkDetected | null>(null);
3839
const [lastSelection, setLastSelection] =
3940
useState<OnChangeSelectionEvent | null>(null);
41+
const [onLinkPressEnabled, setOnLinkPressEnabled] = useState(false);
42+
const [lastOnLinkPress, setLastOnLinkPress] =
43+
useState<OnLinkPressEvent | null>(null);
4044

4145
useEffect(() => {
4246
setLinkRegexError('');
@@ -74,10 +78,31 @@ export function TestLinks() {
7478
onChangeSelection={(e) => {
7579
setLastSelection(e.nativeEvent);
7680
}}
81+
onLinkPress={
82+
onLinkPressEnabled
83+
? (e) => {
84+
setLastOnLinkPress(e);
85+
}
86+
: undefined
87+
}
7788
linkRegex={appliedLinkRegex}
7889
/>
7990
</div>
8091

92+
<div>
93+
<label>
94+
onLinkPress enabled{' '}
95+
<input
96+
data-testid="test-links-onlinkpress-enabled"
97+
type="checkbox"
98+
checked={onLinkPressEnabled}
99+
onChange={(e) => {
100+
setOnLinkPressEnabled(e.target.checked);
101+
}}
102+
/>
103+
</label>
104+
</div>
105+
81106
<div>
82107
<label>
83108
Autolink regex mode{' '}
@@ -254,6 +279,10 @@ export function TestLinks() {
254279
{JSON.stringify(lastOnLinkDetected)}
255280
</pre>
256281

282+
<pre data-testid="on-link-press-payload">
283+
{JSON.stringify(lastOnLinkPress)}
284+
</pre>
285+
257286
<pre data-testid="test-links-html-output">{editorHtml}</pre>
258287
</div>
259288
);

src/types.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ export interface HtmlStyle {
222222
a?: {
223223
color?: ColorValue;
224224
textDecorationLine?: 'underline' | 'none';
225+
/** @platform web */
226+
pressColor?: ColorValue;
225227
};
226228
mention?: Record<string, MentionStyleProperties> | MentionStyleProperties;
227229
ol?: {
@@ -723,6 +725,15 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
723725
/** Called when the editor auto-detects a URL matching `linkRegex`. */
724726
onLinkDetected?: (e: OnLinkDetected) => void;
725727

728+
/**
729+
* Web only. Called when the user clicks a link inside the editor. If not
730+
* provided, clicking a link has no effect (the default, cross-platform
731+
* behavior).
732+
*
733+
* @platform web
734+
*/
735+
onLinkPress?: (event: OnLinkPressEvent) => void;
736+
726737
/** Called when the editor resolves a mention node. */
727738
onMentionDetected?: (e: OnMentionDetected) => void;
728739

@@ -903,7 +914,10 @@ export interface EnrichedTextHtmlStyle extends Omit<
903914
HtmlStyle,
904915
'a' | 'mention'
905916
> {
906-
a?: HtmlStyle['a'] & {
917+
a?: Omit<NonNullable<HtmlStyle['a']>, 'pressColor'> & {
918+
// the documentation comment below is to suppress the base HtmlStyle's
919+
// web-only note about pressColor, as in EnrichedText it is cross-platform
920+
/***/
907921
pressColor?: ColorValue;
908922
};
909923
mention?:

src/utils/defaultHtmlStyle.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const DEFAULT_HTML_STYLE: Required<HtmlStyle> = {
4343
a: {
4444
color: 'blue',
4545
textDecorationLine: 'underline',
46+
pressColor: 'darkblue',
4647
},
4748
mention: {
4849
color: 'blue',
@@ -71,10 +72,6 @@ export const DEFAULT_HTML_STYLE: Required<HtmlStyle> = {
7172

7273
export const DEFAULT_ENRICHED_TEXT_STYLE: Required<EnrichedTextHtmlStyle> = {
7374
...DEFAULT_HTML_STYLE,
74-
a: {
75-
...DEFAULT_HTML_STYLE.a,
76-
pressColor: 'darkblue',
77-
},
7875
mention: {
7976
...DEFAULT_HTML_STYLE.mention,
8077
pressColor: 'darkblue',

src/web/EnrichedText.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,16 @@
138138
transition: none;
139139
}
140140

141-
.et-view a:active {
141+
.et-view a {
142+
cursor: default
143+
}
144+
145+
.et-link-pressable a {
146+
cursor: pointer;
147+
}
148+
149+
.et-link-pressable a:active,
150+
.et-link-pressable a.et-link-pressed {
142151
color: var(--et-link-press-color);
143152
}
144153

src/web/EnrichedText.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import './EnrichedText.css';
1111
import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties';
1212
import { mergeWithDefaultEnrichedTextHtmlStyle } from './styleConversion/htmlStyleToCSSVariables';
1313
import { enrichedTextHtmlStyleToCSSVariables } from './styleConversion/htmlStyleToCSSVariables';
14-
import { ENRICHED_TEXT_CLASSNAME } from './constants/classNames';
14+
import {
15+
ENRICHED_TEXT_CLASSNAME,
16+
LINK_PRESSABLE_CLASSNAME,
17+
} from './constants/classNames';
1518
import { enrichedTextThemingToCSSProperties } from './styleConversion/enrichedThemingToCSSProperties';
1619
import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS';
1720
import { sanitizeHtml } from './sanitization/htmlSanitizer';
@@ -136,7 +139,11 @@ export const EnrichedText = memo(
136139
ref={containerRef}
137140
tabIndex={-1}
138141
style={finalStyle}
139-
className={ENRICHED_TEXT_CLASSNAME}
142+
className={
143+
onLinkPress
144+
? `${ENRICHED_TEXT_CLASSNAME} ${LINK_PRESSABLE_CLASSNAME}`
145+
: ENRICHED_TEXT_CLASSNAME
146+
}
140147
onFocus={(event) =>
141148
onFocus?.(adaptWebToNativeEvent(event, { target: -1 }))
142149
}

src/web/EnrichedTextInput.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin';
7979
import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin';
8080
import { TextShortcutsPlugin } from './pmPlugins/TextShortcutsPlugin';
8181
import { returnKeyTypeToEnterKeyHint } from './nativeMappers/returnKeyTypeToEnterKeyHint';
82-
import { ENRICHED_TEXT_INPUT_CLASSNAME } from './constants/classNames';
82+
import {
83+
ENRICHED_TEXT_INPUT_CLASSNAME,
84+
LINK_PRESSABLE_CLASSNAME,
85+
} from './constants/classNames';
8386
import { AutolinkPlugin } from './pmPlugins/AutolinkPlugin';
8487
import { useStableRef } from './utils/useStableRef';
8588
import {
@@ -88,6 +91,7 @@ import {
8891
} from './sanitization/htmlSanitizer';
8992
import { assertBrowserEnvironment } from './utils/assertBrowserEnvironment';
9093
import { runSafelyInEditor } from './utils/runSafelyInEditor';
94+
import { useLinkPress } from './htmlExtensions/useLinkPress';
9195

9296
function runFocused(
9397
editor: Editor,
@@ -117,6 +121,7 @@ export const EnrichedTextInput = ({
117121
onChangeHtml,
118122
onChangeState,
119123
onLinkDetected,
124+
onLinkPress,
120125
onSubmitEditing,
121126
returnKeyType,
122127
submitBehavior,
@@ -162,6 +167,7 @@ export const EnrichedTextInput = ({
162167
const submitBehaviorRef = useStableRef(submitBehavior);
163168
const onSubmitEditingRef = useStableRef(onSubmitEditing);
164169
const onKeyPressRef = useStableRef(onKeyPress);
170+
const onLinkPressRef = useStableRef(onLinkPress);
165171
const useHtmlNormalizerRef = useStableRef(useHtmlNormalizer);
166172
const sanitizationConfigRef = useStableRef(sanitizationConfig);
167173
const mentionCallbacksRef = useStableRef(mentionCallbacks);
@@ -189,6 +195,10 @@ export const EnrichedTextInput = ({
189195
return false;
190196
};
191197

198+
const { handleLinkPress, handleLinkMouseDown } = useLinkPress(
199+
() => onLinkPressRef.current
200+
);
201+
192202
const linkEmitterRef = useRef<LinkEmitterState>({
193203
linkRegex,
194204
onLinkDetected,
@@ -281,6 +291,10 @@ export const EnrichedTextInput = ({
281291
},
282292
editorProps: {
283293
handleKeyDown: (view, event) => handleKeyDown(view.state.doc, event),
294+
handleDOMEvents: {
295+
click: (_view, event) => handleLinkPress(event),
296+
mousedown: (_view, event) => handleLinkMouseDown(event),
297+
},
284298
handlePaste: (_view, event) =>
285299
handleClipboardPasteImages(
286300
event,
@@ -458,7 +472,11 @@ export const EnrichedTextInput = ({
458472
{mentionRulesCSS ? <style>{mentionRulesCSS}</style> : null}
459473
<EditorContent
460474
editor={editor}
461-
className={ENRICHED_TEXT_INPUT_CLASSNAME}
475+
className={
476+
onLinkPress
477+
? `${ENRICHED_TEXT_INPUT_CLASSNAME} ${LINK_PRESSABLE_CLASSNAME}`
478+
: ENRICHED_TEXT_INPUT_CLASSNAME
479+
}
462480
style={finalStyle}
463481
data-placeholder={placeholder}
464482
/>

src/web/constants/classNames.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export const ENRICHED_TEXT_INPUT_CLASSNAME = 'eti-editor';
22
export const ENRICHED_TEXT_CLASSNAME = 'et-view';
3+
export const LINK_PRESSABLE_CLASSNAME = 'et-link-pressable';
4+
export const LINK_PRESSED_CLASSNAME = 'et-link-pressed';

0 commit comments

Comments
 (0)