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
39 changes: 39 additions & 0 deletions .playwright/tests/links.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const sel = {
'[data-testid="test-links-apply-setlink-from-selection-button"]',
selectionPayload: '[data-testid="test-links-selection-payload"]',
onLinkDetectedPayload: '[data-testid="on-link-detected-payload"]',
onLinkPressEnabled: '[data-testid="test-links-onlinkpress-enabled"]',
onLinkPressPayload: '[data-testid="on-link-press-payload"]',
editorInner: '[data-testid="test-links-editor"] .eti-editor',
editorScreenshot: '[data-testid="test-links-editor"]',
linkRegexMode: '[data-testid="test-links-link-regex-mode"]',
Expand Down Expand Up @@ -63,6 +65,10 @@ async function getOnLinkDetectedPayload(page: Page): Promise<string> {
return (await page.locator(sel.onLinkDetectedPayload).textContent()) ?? '';
}

async function getOnLinkPressPayload(page: Page): Promise<string> {
return (await page.locator(sel.onLinkPressPayload).textContent()) ?? '';
}

test('links display visual regression', async ({ page }) => {
await gotoVisualRegression(page);
const html = [
Expand Down Expand Up @@ -418,6 +424,39 @@ test.describe('test-links onLinkDetected', () => {
});
});

test.describe('test-links onLinkPress', () => {
test('clicking a link does nothing when onLinkPress is not provided', async ({
page,
}) => {
await gotoTestLinks(page);
await setTestLinksEditorHtml(
page,
'<html><p><a href="https://example.com">Example</a></p></html>'
);

await page.locator(sel.editorInner).locator('a').click();

await expect(page.locator(sel.onLinkPressPayload)).toHaveText('null');
});

test('clicking a link fires onLinkPress with the url when provided', async ({
page,
}) => {
await gotoTestLinks(page);
await page.check(sel.onLinkPressEnabled);
await setTestLinksEditorHtml(
page,
'<html><p><a href="https://example.com">Example</a></p></html>'
);

await page.locator(sel.editorInner).locator('a').click();

await expect
.poll(async () => getOnLinkPressPayload(page))
.toBe(JSON.stringify({ url: 'https://example.com' }));
});
});

test.describe('test-links autolink', () => {
async function resetEditorAndSetLinkRegexMode(
page: Page,
Expand Down
6 changes: 6 additions & 0 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type OnSubmitEditing,
type OnChangeMentionEvent,
type OnMentionDetected,
type OnLinkPressEvent,
} from 'react-native-enriched-html';
import { WEB_DEFAULT_HTML_STYLE } from './defaultHtmlStyle';
import type { NativeSyntheticEvent } from 'react-native';
Expand Down Expand Up @@ -224,6 +225,10 @@ function App() {
setCurrentLink(e);
};

const handleLinkPress = (e: OnLinkPressEvent) => {
console.log('[EnrichedTextInput] onLinkPress event', e);
};

const handlePasteImages = (e: NativeSyntheticEvent<OnPasteImagesEvent>) => {
const DEFAULT_W = 80;
const DEFAULT_H = 80;
Expand Down Expand Up @@ -275,6 +280,7 @@ function App() {
onChangeState={handleChangeState}
onSubmitEditing={handleSubmitEditing}
onLinkDetected={handleOnLinkDetected}
onLinkPress={handleLinkPress}
onPasteImages={handlePasteImages}
onStartMention={handleStartMention}
onChangeMention={handleChangeMention}
Expand Down
1 change: 1 addition & 0 deletions apps/example-web/src/defaultHtmlStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const WEB_DEFAULT_HTML_STYLE: HtmlStyle = {
a: {
color: 'green',
textDecorationLine: 'underline',
pressColor: 'darkblue',
},
ol: {
gapWidth: 16,
Expand Down
29 changes: 29 additions & 0 deletions apps/example-web/src/testScreens/TestLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type EnrichedTextInputInstance,
type OnChangeSelectionEvent,
type OnLinkDetected,
type OnLinkPressEvent,
} from 'react-native-enriched-html';
import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle';

Expand Down Expand Up @@ -37,6 +38,9 @@ export function TestLinks() {
useState<OnLinkDetected | null>(null);
const [lastSelection, setLastSelection] =
useState<OnChangeSelectionEvent | null>(null);
const [onLinkPressEnabled, setOnLinkPressEnabled] = useState(false);
const [lastOnLinkPress, setLastOnLinkPress] =
useState<OnLinkPressEvent | null>(null);

useEffect(() => {
setLinkRegexError('');
Expand Down Expand Up @@ -74,10 +78,31 @@ export function TestLinks() {
onChangeSelection={(e) => {
setLastSelection(e.nativeEvent);
}}
onLinkPress={
onLinkPressEnabled
? (e) => {
setLastOnLinkPress(e);
}
: undefined
}
linkRegex={appliedLinkRegex}
/>
</div>

<div>
<label>
onLinkPress enabled{' '}
<input
data-testid="test-links-onlinkpress-enabled"
type="checkbox"
checked={onLinkPressEnabled}
onChange={(e) => {
setOnLinkPressEnabled(e.target.checked);
}}
/>
</label>
</div>

<div>
<label>
Autolink regex mode{' '}
Expand Down Expand Up @@ -254,6 +279,10 @@ export function TestLinks() {
{JSON.stringify(lastOnLinkDetected)}
</pre>

<pre data-testid="on-link-press-payload">
{JSON.stringify(lastOnLinkPress)}
</pre>

<pre data-testid="test-links-html-output">{editorHtml}</pre>
</div>
);
Expand Down
16 changes: 15 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ export interface HtmlStyle {
a?: {
color?: ColorValue;
textDecorationLine?: 'underline' | 'none';
/** @platform web */
pressColor?: ColorValue;
};
mention?: Record<string, MentionStyleProperties> | MentionStyleProperties;
ol?: {
Expand Down Expand Up @@ -723,6 +725,15 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
/** Called when the editor auto-detects a URL matching `linkRegex`. */
onLinkDetected?: (e: OnLinkDetected) => void;

/**
* Web only. Called when the user clicks a link inside the editor. If not
* provided, clicking a link has no effect (the default, cross-platform
* behavior).
*
* @platform web
*/
onLinkPress?: (event: OnLinkPressEvent) => void;

/** Called when the editor resolves a mention node. */
onMentionDetected?: (e: OnMentionDetected) => void;

Expand Down Expand Up @@ -903,7 +914,10 @@ export interface EnrichedTextHtmlStyle extends Omit<
HtmlStyle,
'a' | 'mention'
> {
a?: HtmlStyle['a'] & {
a?: Omit<NonNullable<HtmlStyle['a']>, 'pressColor'> & {
// the documentation comment below is to suppress the base HtmlStyle's
// web-only note about pressColor, as in EnrichedText it is cross-platform
/***/
pressColor?: ColorValue;
};
mention?:
Expand Down
5 changes: 1 addition & 4 deletions src/utils/defaultHtmlStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const DEFAULT_HTML_STYLE: Required<HtmlStyle> = {
a: {
color: 'blue',
textDecorationLine: 'underline',
pressColor: 'darkblue',
},
mention: {
color: 'blue',
Expand Down Expand Up @@ -71,10 +72,6 @@ export const DEFAULT_HTML_STYLE: Required<HtmlStyle> = {

export const DEFAULT_ENRICHED_TEXT_STYLE: Required<EnrichedTextHtmlStyle> = {
...DEFAULT_HTML_STYLE,
a: {
...DEFAULT_HTML_STYLE.a,
pressColor: 'darkblue',
},
mention: {
...DEFAULT_HTML_STYLE.mention,
pressColor: 'darkblue',
Expand Down
11 changes: 10 additions & 1 deletion src/web/EnrichedText.css
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,16 @@
transition: none;
}

.et-view a:active {
.et-view a {
cursor: default
}

.et-link-pressable a {
cursor: pointer;
}

.et-link-pressable a:active,
.et-link-pressable a.et-link-pressed {
color: var(--et-link-press-color);
}

Expand Down
11 changes: 9 additions & 2 deletions src/web/EnrichedText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import './EnrichedText.css';
import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties';
import { mergeWithDefaultEnrichedTextHtmlStyle } from './styleConversion/htmlStyleToCSSVariables';
import { enrichedTextHtmlStyleToCSSVariables } from './styleConversion/htmlStyleToCSSVariables';
import { ENRICHED_TEXT_CLASSNAME } from './constants/classNames';
import {
ENRICHED_TEXT_CLASSNAME,
LINK_PRESSABLE_CLASSNAME,
} from './constants/classNames';
import { enrichedTextThemingToCSSProperties } from './styleConversion/enrichedThemingToCSSProperties';
import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS';
import { sanitizeHtml } from './sanitization/htmlSanitizer';
Expand Down Expand Up @@ -136,7 +139,11 @@ export const EnrichedText = memo(
ref={containerRef}
tabIndex={-1}
style={finalStyle}
className={ENRICHED_TEXT_CLASSNAME}
className={
onLinkPress
? `${ENRICHED_TEXT_CLASSNAME} ${LINK_PRESSABLE_CLASSNAME}`
: ENRICHED_TEXT_CLASSNAME
}
onFocus={(event) =>
onFocus?.(adaptWebToNativeEvent(event, { target: -1 }))
}
Expand Down
22 changes: 20 additions & 2 deletions src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin';
import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin';
import { TextShortcutsPlugin } from './pmPlugins/TextShortcutsPlugin';
import { returnKeyTypeToEnterKeyHint } from './nativeMappers/returnKeyTypeToEnterKeyHint';
import { ENRICHED_TEXT_INPUT_CLASSNAME } from './constants/classNames';
import {
ENRICHED_TEXT_INPUT_CLASSNAME,
LINK_PRESSABLE_CLASSNAME,
} from './constants/classNames';
import { AutolinkPlugin } from './pmPlugins/AutolinkPlugin';
import { useStableRef } from './utils/useStableRef';
import {
Expand All @@ -88,6 +91,7 @@ import {
} from './sanitization/htmlSanitizer';
import { assertBrowserEnvironment } from './utils/assertBrowserEnvironment';
import { runSafelyInEditor } from './utils/runSafelyInEditor';
import { useLinkPress } from './htmlExtensions/useLinkPress';

function runFocused(
editor: Editor,
Expand Down Expand Up @@ -117,6 +121,7 @@ export const EnrichedTextInput = ({
onChangeHtml,
onChangeState,
onLinkDetected,
onLinkPress,
onSubmitEditing,
returnKeyType,
submitBehavior,
Expand Down Expand Up @@ -162,6 +167,7 @@ export const EnrichedTextInput = ({
const submitBehaviorRef = useStableRef(submitBehavior);
const onSubmitEditingRef = useStableRef(onSubmitEditing);
const onKeyPressRef = useStableRef(onKeyPress);
const onLinkPressRef = useStableRef(onLinkPress);
const useHtmlNormalizerRef = useStableRef(useHtmlNormalizer);
const sanitizationConfigRef = useStableRef(sanitizationConfig);
const mentionCallbacksRef = useStableRef(mentionCallbacks);
Expand Down Expand Up @@ -189,6 +195,10 @@ export const EnrichedTextInput = ({
return false;
};

const { handleLinkPress, handleLinkMouseDown } = useLinkPress(
() => onLinkPressRef.current
);

const linkEmitterRef = useRef<LinkEmitterState>({
linkRegex,
onLinkDetected,
Expand Down Expand Up @@ -281,6 +291,10 @@ export const EnrichedTextInput = ({
},
editorProps: {
handleKeyDown: (view, event) => handleKeyDown(view.state.doc, event),
handleDOMEvents: {
click: (_view, event) => handleLinkPress(event),
mousedown: (_view, event) => handleLinkMouseDown(event),
},
handlePaste: (_view, event) =>
handleClipboardPasteImages(
event,
Expand Down Expand Up @@ -458,7 +472,11 @@ export const EnrichedTextInput = ({
{mentionRulesCSS ? <style>{mentionRulesCSS}</style> : null}
<EditorContent
editor={editor}
className={ENRICHED_TEXT_INPUT_CLASSNAME}
className={
onLinkPress
? `${ENRICHED_TEXT_INPUT_CLASSNAME} ${LINK_PRESSABLE_CLASSNAME}`
: ENRICHED_TEXT_INPUT_CLASSNAME
}
style={finalStyle}
data-placeholder={placeholder}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/web/constants/classNames.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export const ENRICHED_TEXT_INPUT_CLASSNAME = 'eti-editor';
export const ENRICHED_TEXT_CLASSNAME = 'et-view';
export const LINK_PRESSABLE_CLASSNAME = 'et-link-pressable';
export const LINK_PRESSED_CLASSNAME = 'et-link-pressed';
41 changes: 41 additions & 0 deletions src/web/htmlExtensions/useLinkPress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react';
import type { EnrichedTextInputProps } from '../..';
import { LINK_PRESSED_CLASSNAME } from '../constants/classNames';

export function useLinkPress(
getOnLinkPress: () => EnrichedTextInputProps['onLinkPress']
) {
const pressedLinkRef = useRef<HTMLElement | null>(null);

const handleLinkPress = (event: PointerEvent): boolean => {
const onPress = getOnLinkPress();
if (!onPress) return false;
const anchor = (event.target as HTMLElement).closest?.('a');
if (!anchor) return false;
const url = anchor.getAttribute('href');
if (!url) return false;
event.preventDefault();
onPress({ url });
return true;
};

const handleLinkMouseDown = (event: MouseEvent): boolean => {
if (!getOnLinkPress()) return false;
const anchor = (event.target as HTMLElement).closest?.('a');
if (!anchor) return false;
anchor.classList.add(LINK_PRESSED_CLASSNAME);
pressedLinkRef.current = anchor;
return false;
};
Comment thread
hejsztynx marked this conversation as resolved.

useEffect(() => {
const clearPressedLink = () => {
pressedLinkRef.current?.classList.remove(LINK_PRESSED_CLASSNAME);
pressedLinkRef.current = null;
};
document.addEventListener('mouseup', clearPressedLink);
return () => document.removeEventListener('mouseup', clearPressedLink);
}, []);

return { handleLinkPress, handleLinkMouseDown };
}
Loading
Loading