diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts index 0563b9d7a..12329a338 100644 --- a/.playwright/tests/links.spec.ts +++ b/.playwright/tests/links.spec.ts @@ -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"]', @@ -63,6 +65,10 @@ async function getOnLinkDetectedPayload(page: Page): Promise { return (await page.locator(sel.onLinkDetectedPayload).textContent()) ?? ''; } +async function getOnLinkPressPayload(page: Page): Promise { + return (await page.locator(sel.onLinkPressPayload).textContent()) ?? ''; +} + test('links display visual regression', async ({ page }) => { await gotoVisualRegression(page); const html = [ @@ -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, + '

Example

' + ); + + 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, + '

Example

' + ); + + 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, diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 3a6409919..5718b75de 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -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'; @@ -224,6 +225,10 @@ function App() { setCurrentLink(e); }; + const handleLinkPress = (e: OnLinkPressEvent) => { + console.log('[EnrichedTextInput] onLinkPress event', e); + }; + const handlePasteImages = (e: NativeSyntheticEvent) => { const DEFAULT_W = 80; const DEFAULT_H = 80; @@ -275,6 +280,7 @@ function App() { onChangeState={handleChangeState} onSubmitEditing={handleSubmitEditing} onLinkDetected={handleOnLinkDetected} + onLinkPress={handleLinkPress} onPasteImages={handlePasteImages} onStartMention={handleStartMention} onChangeMention={handleChangeMention} diff --git a/apps/example-web/src/defaultHtmlStyle.ts b/apps/example-web/src/defaultHtmlStyle.ts index 686d17568..10f10c0cc 100644 --- a/apps/example-web/src/defaultHtmlStyle.ts +++ b/apps/example-web/src/defaultHtmlStyle.ts @@ -43,6 +43,7 @@ export const WEB_DEFAULT_HTML_STYLE: HtmlStyle = { a: { color: 'green', textDecorationLine: 'underline', + pressColor: 'darkblue', }, ol: { gapWidth: 16, diff --git a/apps/example-web/src/testScreens/TestLinks.tsx b/apps/example-web/src/testScreens/TestLinks.tsx index 27635af6b..745e9c912 100644 --- a/apps/example-web/src/testScreens/TestLinks.tsx +++ b/apps/example-web/src/testScreens/TestLinks.tsx @@ -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'; @@ -37,6 +38,9 @@ export function TestLinks() { useState(null); const [lastSelection, setLastSelection] = useState(null); + const [onLinkPressEnabled, setOnLinkPressEnabled] = useState(false); + const [lastOnLinkPress, setLastOnLinkPress] = + useState(null); useEffect(() => { setLinkRegexError(''); @@ -74,10 +78,31 @@ export function TestLinks() { onChangeSelection={(e) => { setLastSelection(e.nativeEvent); }} + onLinkPress={ + onLinkPressEnabled + ? (e) => { + setLastOnLinkPress(e); + } + : undefined + } linkRegex={appliedLinkRegex} /> +
+ +
+
); diff --git a/src/types.ts b/src/types.ts index 125536afb..2c88ff8a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -222,6 +222,8 @@ export interface HtmlStyle { a?: { color?: ColorValue; textDecorationLine?: 'underline' | 'none'; + /** @platform web */ + pressColor?: ColorValue; }; mention?: Record | MentionStyleProperties; ol?: { @@ -723,6 +725,15 @@ export interface EnrichedTextInputProps extends Omit { /** 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; @@ -903,7 +914,10 @@ export interface EnrichedTextHtmlStyle extends Omit< HtmlStyle, 'a' | 'mention' > { - a?: HtmlStyle['a'] & { + a?: Omit, '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?: diff --git a/src/utils/defaultHtmlStyle.ts b/src/utils/defaultHtmlStyle.ts index 5fe0106c2..06666b7ad 100644 --- a/src/utils/defaultHtmlStyle.ts +++ b/src/utils/defaultHtmlStyle.ts @@ -43,6 +43,7 @@ export const DEFAULT_HTML_STYLE: Required = { a: { color: 'blue', textDecorationLine: 'underline', + pressColor: 'darkblue', }, mention: { color: 'blue', @@ -71,10 +72,6 @@ export const DEFAULT_HTML_STYLE: Required = { export const DEFAULT_ENRICHED_TEXT_STYLE: Required = { ...DEFAULT_HTML_STYLE, - a: { - ...DEFAULT_HTML_STYLE.a, - pressColor: 'darkblue', - }, mention: { ...DEFAULT_HTML_STYLE.mention, pressColor: 'darkblue', diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 90018746e..dd63c702d 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -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); } diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index ecf2927af..0863b4f7b 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -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'; @@ -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 })) } diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index e30932c44..1002b647c 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -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 { @@ -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, @@ -117,6 +121,7 @@ export const EnrichedTextInput = ({ onChangeHtml, onChangeState, onLinkDetected, + onLinkPress, onSubmitEditing, returnKeyType, submitBehavior, @@ -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); @@ -189,6 +195,10 @@ export const EnrichedTextInput = ({ return false; }; + const { handleLinkPress, handleLinkMouseDown } = useLinkPress( + () => onLinkPressRef.current + ); + const linkEmitterRef = useRef({ linkRegex, onLinkDetected, @@ -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, @@ -458,7 +472,11 @@ export const EnrichedTextInput = ({ {mentionRulesCSS ? : null} diff --git a/src/web/constants/classNames.ts b/src/web/constants/classNames.ts index 7d45c3774..9d465071c 100644 --- a/src/web/constants/classNames.ts +++ b/src/web/constants/classNames.ts @@ -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'; diff --git a/src/web/htmlExtensions/useLinkPress.ts b/src/web/htmlExtensions/useLinkPress.ts new file mode 100644 index 000000000..fee1cfcac --- /dev/null +++ b/src/web/htmlExtensions/useLinkPress.ts @@ -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(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; + }; + + 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 }; +} diff --git a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts index 84e5a73f6..cd1ca231a 100644 --- a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts +++ b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts @@ -16,7 +16,6 @@ const defaultMentionOnlyResolved = { default: { ...DEFAULT_HTML_STYLE.mention }, }; -const DEFAULT_LINK_PRESS_COLOR = DEFAULT_ENRICHED_TEXT_STYLE.a.pressColor; const DEFAULT_MENTION_PRESS = DEFAULT_ENRICHED_TEXT_STYLE.mention as { pressColor?: string; pressBackgroundColor?: string; @@ -205,11 +204,16 @@ describe('htmlStyleToCSSVariables', () => { it('maps anchor link styles to CSS variables', () => { expect( htmlStyleToCSSVariables({ - a: { color: 'blue', textDecorationLine: 'underline' }, + a: { + color: 'blue', + textDecorationLine: 'underline', + pressColor: 'red', + }, }) ).toMatchObject({ '--et-link-color': 'blue', '--et-link-text-decoration-line': 'underline', + '--et-link-press-color': 'red', }); }); @@ -350,7 +354,6 @@ describe('enrichedTextHtmlStyleToCSSVariables', () => { a: { color: 'blue' }, mention: { color: '#f00' }, }) as Record; - expect(vars['--et-link-press-color']).toBe(DEFAULT_LINK_PRESS_COLOR); expect(vars['--et-mention-default-press-color']).toBe( DEFAULT_MENTION_PRESS_COLOR ); @@ -364,7 +367,6 @@ describe('enrichedTextHtmlStyleToCSSVariables', () => { string, string >; - expect(vars['--et-link-press-color']).toBe(DEFAULT_LINK_PRESS_COLOR); expect(vars['--et-mention-default-press-color']).toBe( DEFAULT_MENTION_PRESS_COLOR ); diff --git a/src/web/styleConversion/htmlStyleToCSSVariables.ts b/src/web/styleConversion/htmlStyleToCSSVariables.ts index f88de3898..c25a4859a 100644 --- a/src/web/styleConversion/htmlStyleToCSSVariables.ts +++ b/src/web/styleConversion/htmlStyleToCSSVariables.ts @@ -60,11 +60,6 @@ export function mergeWithDefaultEnrichedTextHtmlStyle( DEFAULT_ENRICHED_TEXT_STYLE ); - const a = { - ...DEFAULT_ENRICHED_TEXT_STYLE.a, - ...style?.a, - }; - const mentionDefaults = DEFAULT_ENRICHED_TEXT_STYLE.mention; const passedMentionMap = htmlStyle?.mention; const mergedMentionMap = merged.mention as Record< @@ -84,7 +79,6 @@ export function mergeWithDefaultEnrichedTextHtmlStyle( return { ...merged, - a, mention, } as Required; } @@ -101,6 +95,7 @@ const ET_CSS_VARS = { codeblockBorderRadius: '--et-codeblock-border-radius', linkColor: '--et-link-color', linkTextDecorationLine: '--et-link-text-decoration-line', + linkPressColor: '--et-link-press-color', ulBulletColor: '--et-ul-bullet-color', ulBulletSize: '--et-ul-bullet-size', ulMarginLeft: '--et-ul-margin-left', @@ -189,6 +184,7 @@ function applyLinkVars( if (anchor?.textDecorationLine != null) { vars[ET_CSS_VARS.linkTextDecorationLine] = anchor.textDecorationLine; } + setColorVar(vars, ET_CSS_VARS.linkPressColor, anchor?.pressColor); } function applyUnorderedListVars( @@ -278,8 +274,6 @@ export function htmlStyleToCSSVariables(htmlStyle: HtmlStyle): CSSProperties { return vars as CSSProperties; } -const ET_LINK_PRESS_COLOR_VAR = '--et-link-press-color'; - export const ET_MENTION_PRESS_CSS_VARS = { pressColor: (indicator: string) => `--et-mention-${indicatorToMentionCssKey(indicator)}-press-color`, @@ -290,17 +284,6 @@ export const ET_MENTION_PRESS_CSS_VARS = { const DEFAULT_MENTION_PRESS = DEFAULT_ENRICHED_TEXT_STYLE.mention as EnrichedTextMentionStyleProperties; -function expandVarsWithEnrichedTextLink( - vars: Record, - anchor?: EnrichedTextHtmlStyle['a'] -): void { - setColorVar( - vars, - ET_LINK_PRESS_COLOR_VAR, - anchor?.pressColor ?? DEFAULT_ENRICHED_TEXT_STYLE.a.pressColor - ); -} - function expandVarsWithEnrichedTextMention( vars: Record, mention?: EnrichedTextHtmlStyle['mention'] @@ -337,7 +320,6 @@ function expandCSSPropertiesWithEnrichedTextHtmlStyle( cssProperties: CSSProperties ): CSSProperties { const vars = { ...cssProperties } as Record; - expandVarsWithEnrichedTextLink(vars, htmlStyle?.a); expandVarsWithEnrichedTextMention(vars, htmlStyle?.mention); return vars as CSSProperties; }