From facde433b3fb5f17cb6d8ba9044bb955ba52fd86 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 22 Jun 2026 15:24:30 +0200 Subject: [PATCH 01/12] feat: mention and link press css vars --- src/web/EnrichedText.tsx | 8 +- .../__tests__/htmlStyleToCSSVariables.test.ts | 124 +++++++++++++++++- .../htmlStyleToCSSVariables.ts | 77 +++++++++++ 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index c26efb58..b328a1f6 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -2,10 +2,8 @@ import { memo, useMemo, useRef, type CSSProperties } from 'react'; import type { EnrichedTextProps } from '../types'; import './EnrichedText.css'; import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties'; -import { - htmlStyleToCSSVariables, - mergeWithDefaultEnrichedTextHtmlStyle, -} from './styleConversion/htmlStyleToCSSVariables'; +import { mergeWithDefaultEnrichedTextHtmlStyle } from './styleConversion/htmlStyleToCSSVariables'; +import { enrichedTextHtmlStyleToCSSVariables } from './styleConversion/htmlStyleToCSSVariables'; import { ENRICHED_TEXT_CLASSNAME } from './constants/classNames'; import { enrichedInputThemingToCSSProperties } from './styleConversion/enrichedThemingToCSSProperties'; import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS'; @@ -38,7 +36,7 @@ export const EnrichedText = memo( const cssVars = useMemo( () => ({ - ...htmlStyleToCSSVariables(resolvedHtmlStyle), + ...enrichedTextHtmlStyleToCSSVariables(resolvedHtmlStyle), ...INLINE_IMAGE_CSS_VARIABLES, }), [resolvedHtmlStyle] diff --git a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts index 67076eae..9d1df8c1 100644 --- a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts +++ b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts @@ -1,7 +1,11 @@ import type { CSSProperties } from 'react'; import type { HtmlStyle, MentionStyleProperties } from '../../../types'; -import { DEFAULT_HTML_STYLE } from '../../../utils/defaultHtmlStyle'; import { + DEFAULT_ENRICHED_TEXT_STYLE, + DEFAULT_HTML_STYLE, +} from '../../../utils/defaultHtmlStyle'; +import { + enrichedTextHtmlStyleToCSSVariables, htmlStyleToCSSVariables, mergeWithDefaultHtmlStyle, } from '../htmlStyleToCSSVariables'; @@ -12,6 +16,15 @@ 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; +}; +const DEFAULT_MENTION_PRESS_COLOR = DEFAULT_MENTION_PRESS.pressColor; +const DEFAULT_MENTION_PRESS_BACKGROUND_COLOR = + DEFAULT_MENTION_PRESS.pressBackgroundColor; + describe('mergeWithDefaultHtmlStyle', () => { it('undefined → default mention key', () => { expect(mergeWithDefaultHtmlStyle(undefined)).toMatchObject({ @@ -283,3 +296,112 @@ describe('mention CSS variables', () => { ); }); }); + +describe('enrichedTextHtmlStyleToCSSVariables', () => { + it('keeps the base CSS variables', () => { + expect( + enrichedTextHtmlStyleToCSSVariables({ + a: { color: 'blue' }, + code: { color: 'purple' }, + }) + ).toMatchObject({ + '--et-link-color': 'blue', + '--et-code-color': 'purple', + }); + }); + + it('adds the link press color variable', () => { + const vars = enrichedTextHtmlStyleToCSSVariables({ + a: { color: 'blue', pressColor: 'darkblue' }, + }) as Record; + expect(vars['--et-link-press-color']).toBe('darkblue'); + }); + + it('adds two press variables for a flat mention (default key)', () => { + const vars = enrichedTextHtmlStyleToCSSVariables({ + mention: { pressColor: '#123', pressBackgroundColor: '#456' }, + }) as Record; + expect(vars['--et-mention-default-press-color']).toBe('#123'); + expect(vars['--et-mention-default-press-background-color']).toBe('#456'); + }); + + it('adds two press variables per mention record entry', () => { + const vars = enrichedTextHtmlStyleToCSSVariables({ + mention: { + '@': { + color: '#f00', + pressColor: '#a00', + pressBackgroundColor: '#fee', + }, + '#': { pressColor: '#0a0' }, + }, + }) as Record; + expect(vars['--et-mention-u0040-color']).toBe('#f00'); + expect(vars['--et-mention-u0040-press-color']).toBe('#a00'); + expect(vars['--et-mention-u0040-press-background-color']).toBe('#fee'); + expect(vars['--et-mention-u0023-press-color']).toBe('#0a0'); + expect(vars['--et-mention-u0023-press-background-color']).toBe( + DEFAULT_MENTION_PRESS_BACKGROUND_COLOR + ); + }); + + it('falls back to the global defaults when press values are absent', () => { + const vars = 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 + ); + expect(vars['--et-mention-default-press-background-color']).toBe( + DEFAULT_MENTION_PRESS_BACKGROUND_COLOR + ); + }); + + it('emits the global defaults even with no htmlStyle', () => { + const vars = enrichedTextHtmlStyleToCSSVariables() as Record< + 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 + ); + expect(vars['--et-mention-default-press-background-color']).toBe( + DEFAULT_MENTION_PRESS_BACKGROUND_COLOR + ); + }); + + it('falls back from a record indicator to the provided default indicator', () => { + const vars = enrichedTextHtmlStyleToCSSVariables({ + mention: { + 'default': { pressColor: '#0d0', pressBackgroundColor: '#eee' }, + '@': { color: '#f00' }, + }, + }) as Record; + expect(vars['--et-mention-u0040-press-color']).toBe('#0d0'); + expect(vars['--et-mention-u0040-press-background-color']).toBe('#eee'); + expect(vars['--et-mention-default-press-color']).toBe('#0d0'); + expect(vars['--et-mention-default-press-background-color']).toBe('#eee'); + }); + + it('mixes default-indicator and global-constant fallbacks', () => { + const vars = enrichedTextHtmlStyleToCSSVariables({ + mention: { + 'default': { pressColor: '#0d0' }, + '@': { color: '#f00' }, + }, + }) as Record; + // pressColor resolves to the `default` indicator... + expect(vars['--et-mention-u0040-press-color']).toBe('#0d0'); + // ...while pressBackgroundColor falls through to the global constant. + expect(vars['--et-mention-u0040-press-background-color']).toBe( + DEFAULT_MENTION_PRESS_BACKGROUND_COLOR + ); + expect(vars['--et-mention-default-press-color']).toBe('#0d0'); + expect(vars['--et-mention-default-press-background-color']).toBe( + DEFAULT_MENTION_PRESS_BACKGROUND_COLOR + ); + }); +}); diff --git a/src/web/styleConversion/htmlStyleToCSSVariables.ts b/src/web/styleConversion/htmlStyleToCSSVariables.ts index ae72edd5..6a0e45fa 100644 --- a/src/web/styleConversion/htmlStyleToCSSVariables.ts +++ b/src/web/styleConversion/htmlStyleToCSSVariables.ts @@ -275,3 +275,80 @@ export function htmlStyleToCSSVariables(htmlStyle: HtmlStyle): CSSProperties { ); return vars as CSSProperties; } + +const ET_LINK_PRESS_COLOR_VAR = '--et-link-press-color'; + +const ET_MENTION_PRESS_CSS_VARS = { + pressColor: (indicator: string) => + `--et-mention-${indicatorToMentionCssKey(indicator)}-press-color`, + pressBackgroundColor: (indicator: string) => + `--et-mention-${indicatorToMentionCssKey(indicator)}-press-background-color`, +} as const; + +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'] +): void { + const mentionIndicators = isMentionStyleRecord(mention) + ? Object.keys(mention) + : []; + + if (!mentionIndicators.includes(MENTION_STYLE_DEFAULT_KEY)) + mentionIndicators.push(MENTION_STYLE_DEFAULT_KEY); + + for (const indicator of mentionIndicators) { + const style = isMentionStyleRecord(mention) + ? mention?.[indicator] + : mention; + + setColorVar( + vars, + ET_MENTION_PRESS_CSS_VARS.pressColor(indicator), + style?.pressColor ?? + (isMentionStyleRecord(mention) + ? mention.default?.pressColor + : undefined) ?? + DEFAULT_MENTION_PRESS.pressColor + ); + setColorVar( + vars, + ET_MENTION_PRESS_CSS_VARS.pressBackgroundColor(indicator), + style?.pressBackgroundColor ?? + (isMentionStyleRecord(mention) + ? mention.default?.pressBackgroundColor + : undefined) ?? + DEFAULT_MENTION_PRESS.pressBackgroundColor + ); + } +} + +function expandCSSPropertiesWithEnrichedTextHtmlStyle( + htmlStyle: EnrichedTextHtmlStyle | undefined, + cssProperties: CSSProperties +): CSSProperties { + const vars = { ...cssProperties } as Record; + expandVarsWithEnrichedTextLink(vars, htmlStyle?.a); + expandVarsWithEnrichedTextMention(vars, htmlStyle?.mention); + return vars as CSSProperties; +} + +export function enrichedTextHtmlStyleToCSSVariables( + htmlStyle?: EnrichedTextHtmlStyle +): CSSProperties { + const vars = htmlStyleToCSSVariables(htmlStyle); + return expandCSSPropertiesWithEnrichedTextHtmlStyle(htmlStyle, vars); +} From 4adcd48bec27fa9766f594d05a9e3982be16a287 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 22 Jun 2026 16:30:58 +0200 Subject: [PATCH 02/12] fix: css bleed with mention rules --- src/web/EnrichedText.css | 4 ++++ .../__tests__/buildMentionRulesCSS.test.ts | 23 ++++++++++++++++++ .../__tests__/htmlStyleToCSSVariables.test.ts | 4 +--- .../styleConversion/buildMentionRulesCSS.ts | 24 +++++++++++++++---- .../htmlStyleToCSSVariables.ts | 24 ++++++++++--------- 5 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 0e8878b4..d95a8e67 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -118,6 +118,10 @@ text-decoration-line: var(--et-link-text-decoration-line); } +.et-view a:active { + color: var(--et-link-press-color); +} + .eti-editor ul:not([data-type]), .et-view ul:not([data-type]) { margin: 0; diff --git a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts index 613d3f91..df49e5d9 100644 --- a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts +++ b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts @@ -27,6 +27,29 @@ describe('buildMentionRulesCSS', () => { expect(css).toContain('var(--et-mention-u0040-text-decoration-line)'); }); + it('appends press-state rules for the read-only view only', () => { + const merged = mergeWithDefaultHtmlStyle({ + mention: { '@': { color: 'red' } }, + }); + const css = buildMentionRulesCSS(merged); + + expect(css).toContain(`.${ENRICHED_TEXT_CLASSNAME} mention:active`); + expect(css).toContain( + `.${ENRICHED_TEXT_CLASSNAME} mention[indicator="@"]:active` + ); + expect(css).toContain('var(--et-mention-default-press-color)'); + expect(css).toContain('var(--et-mention-default-press-background-color)'); + expect(css).toContain('var(--et-mention-u0040-press-color)'); + expect(css).toContain('var(--et-mention-u0040-press-background-color)'); + + expect(css).not.toContain( + `.${ENRICHED_TEXT_INPUT_CLASSNAME} mention:active` + ); + expect(css).not.toContain( + `.${ENRICHED_TEXT_INPUT_CLASSNAME} mention[indicator="@"]:active` + ); + }); + it('returns empty string when mention is missing', () => { expect(buildMentionRulesCSS(undefined)).toBe(''); expect(buildMentionRulesCSS({})).toBe(''); diff --git a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts index 9d1df8c1..84e5a73f 100644 --- a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts +++ b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts @@ -360,7 +360,7 @@ describe('enrichedTextHtmlStyleToCSSVariables', () => { }); it('emits the global defaults even with no htmlStyle', () => { - const vars = enrichedTextHtmlStyleToCSSVariables() as Record< + const vars = enrichedTextHtmlStyleToCSSVariables({}) as Record< string, string >; @@ -393,9 +393,7 @@ describe('enrichedTextHtmlStyleToCSSVariables', () => { '@': { color: '#f00' }, }, }) as Record; - // pressColor resolves to the `default` indicator... expect(vars['--et-mention-u0040-press-color']).toBe('#0d0'); - // ...while pressBackgroundColor falls through to the global constant. expect(vars['--et-mention-u0040-press-background-color']).toBe( DEFAULT_MENTION_PRESS_BACKGROUND_COLOR ); diff --git a/src/web/styleConversion/buildMentionRulesCSS.ts b/src/web/styleConversion/buildMentionRulesCSS.ts index 624fd2ae..9cc06611 100644 --- a/src/web/styleConversion/buildMentionRulesCSS.ts +++ b/src/web/styleConversion/buildMentionRulesCSS.ts @@ -8,7 +8,10 @@ import { ENRICHED_TEXT_CLASSNAME, ENRICHED_TEXT_INPUT_CLASSNAME, } from '../constants/classNames'; -import { ET_MENTION_CSS_VARS } from './htmlStyleToCSSVariables'; +import { + ET_MENTION_CSS_VARS, + ET_MENTION_PRESS_CSS_VARS, +} from './htmlStyleToCSSVariables'; import { MENTION_STYLE_DEFAULT_KEY } from './mentionIndicatorCssKey'; function escapeIndicatorForCssAttributeSelector(indicator: string): string { @@ -40,6 +43,7 @@ export function buildMentionRulesCSS( const lines: string[] = []; + // Base mention styling - shared by the editable input and the read-only view. for (const indicator of keys) { const inputSelector = mentionSelector( ENRICHED_TEXT_INPUT_CLASSNAME, @@ -50,9 +54,21 @@ export function buildMentionRulesCSS( lines.push( `${inputSelector}, ${textSelector} { - color: var(${ET_MENTION_CSS_VARS.color(indicator)}); - background-color: var(${ET_MENTION_CSS_VARS.backgroundColor(indicator)}); - text-decoration-line: var(${ET_MENTION_CSS_VARS.textDecorationLine(indicator)}); + color: var(${ET_MENTION_CSS_VARS.color(indicator)}, var(${ET_MENTION_CSS_VARS.color(MENTION_STYLE_DEFAULT_KEY)})); + background-color: var(${ET_MENTION_CSS_VARS.backgroundColor(indicator)}, var(${ET_MENTION_CSS_VARS.backgroundColor(MENTION_STYLE_DEFAULT_KEY)})); + text-decoration-line: var(${ET_MENTION_CSS_VARS.textDecorationLine(indicator)}, var(${ET_MENTION_CSS_VARS.textDecorationLine(MENTION_STYLE_DEFAULT_KEY)})); +}`.trim() + ); + } + + // Press-state styling - only the read-only EnrichedText handles presses. + for (const indicator of keys) { + const textSelector = mentionSelector(ENRICHED_TEXT_CLASSNAME, indicator); + + lines.push( + `${textSelector}:active { + color: var(${ET_MENTION_PRESS_CSS_VARS.pressColor(indicator)}, var(${ET_MENTION_PRESS_CSS_VARS.pressColor(MENTION_STYLE_DEFAULT_KEY)})); + background-color: var(${ET_MENTION_PRESS_CSS_VARS.pressBackgroundColor(indicator)}, var(${ET_MENTION_PRESS_CSS_VARS.pressBackgroundColor(MENTION_STYLE_DEFAULT_KEY)})); }`.trim() ); } diff --git a/src/web/styleConversion/htmlStyleToCSSVariables.ts b/src/web/styleConversion/htmlStyleToCSSVariables.ts index 6a0e45fa..eb39f208 100644 --- a/src/web/styleConversion/htmlStyleToCSSVariables.ts +++ b/src/web/styleConversion/htmlStyleToCSSVariables.ts @@ -53,14 +53,16 @@ export function mergeWithDefaultHtmlStyle( export function mergeWithDefaultEnrichedTextHtmlStyle( htmlStyle?: EnrichedTextHtmlStyle ): Required { + const style = htmlStyle ?? {}; + const merged = mergeWithDefaultHtmlStyle( - htmlStyle as HtmlStyle, + style as HtmlStyle, DEFAULT_ENRICHED_TEXT_STYLE ); const a = { ...DEFAULT_ENRICHED_TEXT_STYLE.a, - ...htmlStyle?.a, + ...style?.a, }; const mentionDefaults = DEFAULT_ENRICHED_TEXT_STYLE.mention; @@ -261,14 +263,14 @@ function expandMentionStylesForIndicatorsIncludeDefault( export function htmlStyleToCSSVariables(htmlStyle: HtmlStyle): CSSProperties { const vars: Record = {}; - applyCodeVars(vars, htmlStyle?.code); + applyCodeVars(vars, htmlStyle.code); applyHeadingVars(vars, htmlStyle); - applyBlockquoteVars(vars, htmlStyle?.blockquote); - applyCodeblockVars(vars, htmlStyle?.codeblock); - applyLinkVars(vars, htmlStyle?.a); - applyUnorderedListVars(vars, htmlStyle?.ul); - applyOrderedListVars(vars, htmlStyle?.ol); - applyCheckboxListVars(vars, htmlStyle?.ulCheckbox); + applyBlockquoteVars(vars, htmlStyle.blockquote); + applyCodeblockVars(vars, htmlStyle.codeblock); + applyLinkVars(vars, htmlStyle.a); + applyUnorderedListVars(vars, htmlStyle.ul); + applyOrderedListVars(vars, htmlStyle.ol); + applyCheckboxListVars(vars, htmlStyle.ulCheckbox); applyMentionVars( vars, htmlStyle.mention as Record @@ -278,7 +280,7 @@ export function htmlStyleToCSSVariables(htmlStyle: HtmlStyle): CSSProperties { const ET_LINK_PRESS_COLOR_VAR = '--et-link-press-color'; -const ET_MENTION_PRESS_CSS_VARS = { +export const ET_MENTION_PRESS_CSS_VARS = { pressColor: (indicator: string) => `--et-mention-${indicatorToMentionCssKey(indicator)}-press-color`, pressBackgroundColor: (indicator: string) => @@ -347,7 +349,7 @@ function expandCSSPropertiesWithEnrichedTextHtmlStyle( } export function enrichedTextHtmlStyleToCSSVariables( - htmlStyle?: EnrichedTextHtmlStyle + htmlStyle: EnrichedTextHtmlStyle ): CSSProperties { const vars = htmlStyleToCSSVariables(htmlStyle); return expandCSSPropertiesWithEnrichedTextHtmlStyle(htmlStyle, vars); From 36ed073c0ee85f9c5de42352b6dd1c640a23ecfb Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 23 Jun 2026 13:17:46 +0200 Subject: [PATCH 03/12] feat: link and mention press handling --- apps/example-web/src/App.tsx | 6 +++ src/web/EnrichedText.tsx | 11 +++++- src/web/sanitization/htmlSanitizer.ts | 20 ++++++++++ src/web/usePressInteractions.ts | 57 +++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/web/usePressInteractions.ts diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 8fa6e035..6135f21e 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -337,6 +337,12 @@ function App() { { + console.log('link press event', e); + }} + onMentionPress={(e) => { + console.log('mention press event', e); + }} > {enrichedTextValue} diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index b328a1f6..096342d4 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -14,7 +14,14 @@ import { useImageErrorFallback } from './useImageErrorFallback'; import { usePressInteractions } from './usePressInteractions'; export const EnrichedText = memo( - ({ children, htmlStyle, style, selectionColor }: EnrichedTextProps) => { + ({ + children, + htmlStyle, + style, + selectionColor, + onLinkPress, + onMentionPress, + }: EnrichedTextProps) => { const containerRef = useRef(null); const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]); @@ -62,8 +69,8 @@ export const EnrichedText = memo( [textStyle, themingStyle, cssVars] ); - usePressInteractions(containerRef); useImageErrorFallback(containerRef); + usePressInteractions(containerRef, onLinkPress, onMentionPress); return ( <> diff --git a/src/web/sanitization/htmlSanitizer.ts b/src/web/sanitization/htmlSanitizer.ts index 7a45adc1..9e12af42 100644 --- a/src/web/sanitization/htmlSanitizer.ts +++ b/src/web/sanitization/htmlSanitizer.ts @@ -1,5 +1,25 @@ import DOMPurify from 'dompurify'; +// We need to explicitly allow custom html attributes that can be set inside a +DOMPurify.addHook('uponSanitizeAttribute', (currentNode, hookEvent) => { + if (currentNode.nodeName.toLowerCase() === 'mention') { + const attrName = hookEvent.attrName.toLowerCase(); + + // sanitize the mention from the tags with risk of XSS injection + if ( + attrName.startsWith('on') || + attrName === 'href' || + attrName === 'src' || + attrName === 'style' + ) { + hookEvent.keepAttr = false; + return; + } + + hookEvent.forceKeepAttr = true; + } +}); + export function sanitizeHtml(html: string) { return DOMPurify.sanitize(html, { ADD_TAGS: ['mention', 'codeblock'], diff --git a/src/web/usePressInteractions.ts b/src/web/usePressInteractions.ts new file mode 100644 index 00000000..67c53393 --- /dev/null +++ b/src/web/usePressInteractions.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; +import type { OnLinkPressEvent, OnMentionPressEvent } from '../types'; + +export function usePressInteractions( + containerRef: React.RefObject, + onLinkPress?: (event: OnLinkPressEvent) => void, + onMentionPress?: (event: OnMentionPressEvent) => void +) { + const linkPressRef = useRef(onLinkPress); + const mentionPressRef = useRef(onMentionPress); + + useEffect(() => { + linkPressRef.current = onLinkPress; + mentionPressRef.current = onMentionPress; + }, [onLinkPress, onMentionPress]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleInteraction = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + const anchor = target.closest('a'); + if (anchor && container.contains(anchor)) { + const url = anchor.getAttribute('href'); + if (url && linkPressRef.current) { + e.preventDefault(); + linkPressRef.current({ url }); + } + } + + const mention = target.closest('mention'); + if (mention && container.contains(mention)) { + if (mentionPressRef.current) { + e.preventDefault(); + + const customAttributes: Record = {}; + for (const attr of Array.from(mention.attributes)) { + if (attr.name !== 'text' && attr.name !== 'indicator') { + customAttributes[attr.name] = attr.value; + } + } + + mentionPressRef.current({ + text: mention.getAttribute('text') ?? '', + indicator: mention.getAttribute('indicator') ?? '', + attributes: customAttributes, + }); + } + } + }; + + container.addEventListener('click', handleInteraction); + return () => container.removeEventListener('click', handleInteraction); + }, [containerRef]); +} From ba27a8f0bba3a499420a9480b68a21011c3e1cb4 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 23 Jun 2026 15:09:21 +0200 Subject: [PATCH 04/12] test: e2e tests update --- .playwright/tests/enrichedTextPress.spec.ts | 230 ++++++++++++++++++ .../src/testScreens/TestEnrichedText.tsx | 24 +- src/web/EnrichedText.css | 2 - .../__tests__/buildMentionRulesCSS.test.ts | 20 +- src/web/usePressInteractions.ts | 5 + 5 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 .playwright/tests/enrichedTextPress.spec.ts diff --git a/.playwright/tests/enrichedTextPress.spec.ts b/.playwright/tests/enrichedTextPress.spec.ts new file mode 100644 index 00000000..51292d18 --- /dev/null +++ b/.playwright/tests/enrichedTextPress.spec.ts @@ -0,0 +1,230 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.setTimeout(90_000); + +const sel = { + root: '[data-testid="test-enriched-text-root"]', + htmlInput: '[data-testid="test-enriched-text-html-input"]', + setValueButton: '[data-testid="test-enriched-text-set-value-button"]', + valueOutput: '[data-testid="test-enriched-text-value-output"]', + display: '[data-testid="test-enriched-text-display"]', + displayInner: '[data-testid="test-enriched-text-display"] .et-view', + linkPressOutput: '[data-testid="test-enriched-text-link-press-output"]', + mentionPressOutput: '[data-testid="test-enriched-text-mention-press-output"]', +} as const; + +async function gotoTestEnrichedText(page: Page): Promise { + await page.goto('/test-enriched-text'); + await page.waitForSelector(sel.displayInner); +} + +async function setEnrichedTextValue(page: Page, html: string): Promise { + await page.fill(sel.htmlInput, html); + await page.click(sel.setValueButton); + + await expect + .poll(async () => (await page.locator(sel.valueOutput).textContent()) ?? '') + .toBe(html); +} + +function linkPress(page: Page) { + return page.locator(sel.linkPressOutput); +} + +function mentionPress(page: Page) { + return page.locator(sel.mentionPressOutput); +} + +test.describe('EnrichedText link press', () => { + test('pressing a link emits onLinkPress with the url', async ({ page }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

Visit our site now.

' + ); + + await expect(linkPress(page)).toHaveText('null'); + + await page.locator(`${sel.displayInner} a`).click(); + + await expect + .poll(async () => + JSON.parse((await linkPress(page).textContent()) || 'null') + ) + .toEqual({ url: 'https://example.com' }); + }); + + test('pressing text styled inside a link still emits onLinkPress', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

A bold link here.

' + ); + + await page.locator(`${sel.displayInner} a b`).click(); + + await expect + .poll(async () => + JSON.parse((await linkPress(page).textContent()) || 'null') + ) + .toEqual({ url: 'https://example.com/path' }); + }); + + test('pressing non-link text does not emit onLinkPress', async ({ page }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

Plain text with a link.

' + ); + + await page + .locator(`${sel.displayInner} p`) + .click({ position: { x: 2, y: 2 } }); + + await expect(linkPress(page)).toHaveText('null'); + }); +}); + +test.describe('EnrichedText mention press', () => { + test('pressing a mention emits onMentionPress with text and indicator', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

Hello @John Doe!

' + ); + + await expect(mentionPress(page)).toHaveText('null'); + + await page.locator(`${sel.displayInner} mention`).click(); + + await expect + .poll(async () => + JSON.parse((await mentionPress(page).textContent()) || 'null') + ) + .toEqual({ text: '@John Doe', indicator: '@', attributes: {} }); + }); + + test('pressing a mention exposes its custom attributes', async ({ page }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

Channel #general here.

' + ); + + await page.locator(`${sel.displayInner} mention`).click(); + + await expect + .poll(async () => + JSON.parse((await mentionPress(page).textContent()) || 'null') + ) + .toEqual({ + text: 'general', + indicator: '#', + attributes: { id: '42', custom: 'custom-attribute', type: 'channel' }, + }); + }); + + test('pressing text styled inside a mention still emits onMentionPress', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

Hi @Jane.

' + ); + + await page.locator(`${sel.displayInner} mention s`).click(); + + await expect + .poll(async () => + JSON.parse((await mentionPress(page).textContent()) || 'null') + ) + .toEqual({ text: '@Jane', indicator: '@', attributes: {} }); + }); + + test('pressing non-mention text does not emit onMentionPress', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

Some text @Jane.

' + ); + + await page + .locator(`${sel.displayInner} p`) + .click({ position: { x: 2, y: 2 } }); + + await expect(mentionPress(page)).toHaveText('null'); + }); +}); + +test.describe('EnrichedText press inside lists', () => { + const listCases = [ + { + name: 'unordered list', + open: '
    ', + close: '
', + }, + { + name: 'ordered list', + open: '
    ', + close: '
', + }, + { + name: 'checkbox list', + open: '
    ', + close: '
', + }, + ]; + + for (const c of listCases) { + test(`pressing a link inside a ${c.name} emits onLinkPress`, async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + `${c.open}
  • Visit our site
  • Other item
  • ${c.close}` + ); + + await expect(linkPress(page)).toHaveText('null'); + + await page.locator(`${sel.displayInner} li a`).click(); + + await expect + .poll(async () => + JSON.parse((await linkPress(page).textContent()) || 'null') + ) + .toEqual({ url: 'https://example.com/list' }); + }); + + test(`pressing a mention inside a ${c.name} emits onMentionPress`, async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + `${c.open}
  • Hello @John Doe
  • Other item
  • ${c.close}` + ); + + await expect(mentionPress(page)).toHaveText('null'); + + await page.locator(`${sel.displayInner} li mention`).click(); + + await expect + .poll(async () => + JSON.parse((await mentionPress(page).textContent()) || 'null') + ) + .toEqual({ + text: '@John Doe', + indicator: '@', + attributes: { id: '1' }, + }); + }); + } +}); diff --git a/apps/example-web/src/testScreens/TestEnrichedText.tsx b/apps/example-web/src/testScreens/TestEnrichedText.tsx index 912031dc..accf6c36 100644 --- a/apps/example-web/src/testScreens/TestEnrichedText.tsx +++ b/apps/example-web/src/testScreens/TestEnrichedText.tsx @@ -1,5 +1,9 @@ import { useState, type ChangeEvent } from 'react'; -import { EnrichedText } from 'react-native-enriched-html'; +import { + EnrichedText, + type OnLinkPressEvent, + type OnMentionPressEvent, +} from 'react-native-enriched-html'; import type { TextStyle } from 'react-native'; import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; @@ -8,6 +12,11 @@ const INITIAL_VALUE = '

    '; export function TestEnrichedText() { const [htmlInput, setHtmlInput] = useState(INITIAL_VALUE); const [value, setValue] = useState(INITIAL_VALUE); + const [lastLinkPress, setLastLinkPress] = useState( + null + ); + const [lastMentionPress, setLastMentionPress] = + useState(null); return (
    @@ -18,6 +27,12 @@ export function TestEnrichedText() { { + setLastLinkPress(event); + }} + onMentionPress={(event) => { + setLastMentionPress(event); + }} > {value} @@ -42,6 +57,13 @@ export function TestEnrichedText() {
    {value}
    + +
    +        {JSON.stringify(lastLinkPress)}
    +      
    +
    +        {JSON.stringify(lastMentionPress)}
    +      
    ); } diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index d95a8e67..7c1a1a09 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -275,7 +275,6 @@ .et-view ul[data-type="checkbox"] > li { position: relative; list-style: none; - pointer-events: none; min-height: max(1lh, var(--et-checkbox-box-size, 24px)); } @@ -297,7 +296,6 @@ gap: 0; margin: 0; padding: 0; - user-select: none; } .et-view img { diff --git a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts index df49e5d9..d1658014 100644 --- a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts +++ b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts @@ -22,9 +22,15 @@ describe('buildMentionRulesCSS', () => { `.${ENRICHED_TEXT_INPUT_CLASSNAME} mention[indicator="@"]` ); expect(css).toContain(`.${ENRICHED_TEXT_CLASSNAME} mention[indicator="@"]`); - expect(css).toContain('var(--et-mention-u0040-color)'); - expect(css).toContain('var(--et-mention-u0040-background-color)'); - expect(css).toContain('var(--et-mention-u0040-text-decoration-line)'); + expect(css).toContain( + 'var(--et-mention-u0040-color, var(--et-mention-default-color))' + ); + expect(css).toContain( + 'var(--et-mention-u0040-background-color, var(--et-mention-default-background-color))' + ); + expect(css).toContain( + 'var(--et-mention-u0040-text-decoration-line, var(--et-mention-default-text-decoration-line))' + ); }); it('appends press-state rules for the read-only view only', () => { @@ -39,8 +45,12 @@ describe('buildMentionRulesCSS', () => { ); expect(css).toContain('var(--et-mention-default-press-color)'); expect(css).toContain('var(--et-mention-default-press-background-color)'); - expect(css).toContain('var(--et-mention-u0040-press-color)'); - expect(css).toContain('var(--et-mention-u0040-press-background-color)'); + expect(css).toContain( + 'var(--et-mention-u0040-press-color, var(--et-mention-default-press-color))' + ); + expect(css).toContain( + 'var(--et-mention-u0040-press-background-color, var(--et-mention-default-press-background-color))' + ); expect(css).not.toContain( `.${ENRICHED_TEXT_INPUT_CLASSNAME} mention:active` diff --git a/src/web/usePressInteractions.ts b/src/web/usePressInteractions.ts index 67c53393..8613a846 100644 --- a/src/web/usePressInteractions.ts +++ b/src/web/usePressInteractions.ts @@ -21,6 +21,11 @@ export function usePressInteractions( const handleInteraction = (e: MouseEvent) => { const target = e.target as HTMLElement; + const checkbox = target.closest("input[type='checkbox']"); + if (checkbox && container.contains(checkbox)) { + e.preventDefault(); + } + const anchor = target.closest('a'); if (anchor && container.contains(anchor)) { const url = anchor.getAttribute('href'); From 70b403f58f5901e5f55ef532cf70080206bcd912 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Sun, 5 Jul 2026 22:42:13 +0200 Subject: [PATCH 05/12] feat: remove mention sanitization --- src/web/sanitization/htmlSanitizer.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/web/sanitization/htmlSanitizer.ts b/src/web/sanitization/htmlSanitizer.ts index 9e12af42..7a45adc1 100644 --- a/src/web/sanitization/htmlSanitizer.ts +++ b/src/web/sanitization/htmlSanitizer.ts @@ -1,25 +1,5 @@ import DOMPurify from 'dompurify'; -// We need to explicitly allow custom html attributes that can be set inside a -DOMPurify.addHook('uponSanitizeAttribute', (currentNode, hookEvent) => { - if (currentNode.nodeName.toLowerCase() === 'mention') { - const attrName = hookEvent.attrName.toLowerCase(); - - // sanitize the mention from the tags with risk of XSS injection - if ( - attrName.startsWith('on') || - attrName === 'href' || - attrName === 'src' || - attrName === 'style' - ) { - hookEvent.keepAttr = false; - return; - } - - hookEvent.forceKeepAttr = true; - } -}); - export function sanitizeHtml(html: string) { return DOMPurify.sanitize(html, { ADD_TAGS: ['mention', 'codeblock'], From d9c5fb1ef119a8ee8c802c92a682ef02b395cc71 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 10:12:55 +0200 Subject: [PATCH 06/12] refactor: stableRef usage --- apps/example-web/src/App.tsx | 18 ++++++++++++------ src/web/EnrichedText.tsx | 6 +++++- src/web/usePressInteractions.ts | 32 ++++++++++++++++---------------- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 6135f21e..ca814cbe 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -15,6 +15,8 @@ import { type OnChangeMentionEvent, type OnMentionDetected, EnrichedText, + type OnLinkPressEvent, + type OnMentionPressEvent, } from 'react-native-enriched-html'; import { WEB_DEFAULT_HTML_STYLE } from './defaultHtmlStyle'; import type { NativeSyntheticEvent, TextStyle } from 'react-native'; @@ -173,6 +175,14 @@ function App() { setSelection(e.nativeEvent); }; + const handleLinkPress = (e: OnLinkPressEvent) => { + console.log('[EnrichedTextInput] link press event', e); + }; + + const handleMentionPress = (e: OnMentionPressEvent) => { + console.log('[EnrichedTextInput] link mention event', e); + }; + const openLinkModal = () => { setIsLinkModalOpen(true); }; @@ -337,12 +347,8 @@ function App() { { - console.log('link press event', e); - }} - onMentionPress={(e) => { - console.log('mention press event', e); - }} + onLinkPress={handleLinkPress} + onMentionPress={handleMentionPress} > {enrichedTextValue} diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 096342d4..75bd7268 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -12,6 +12,7 @@ import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb'; import { INLINE_IMAGE_CSS_VARIABLES } from './styleConversion/inlineImageCSSVariables'; import { useImageErrorFallback } from './useImageErrorFallback'; import { usePressInteractions } from './usePressInteractions'; +import { useStableRef } from './useStableRef'; export const EnrichedText = memo( ({ @@ -69,8 +70,11 @@ export const EnrichedText = memo( [textStyle, themingStyle, cssVars] ); + const onLinkPressRef = useStableRef(onLinkPress); + const onMentionPressRef = useStableRef(onMentionPress); + useImageErrorFallback(containerRef); - usePressInteractions(containerRef, onLinkPress, onMentionPress); + usePressInteractions(containerRef, onLinkPressRef, onMentionPressRef); return ( <> diff --git a/src/web/usePressInteractions.ts b/src/web/usePressInteractions.ts index 8613a846..3c158496 100644 --- a/src/web/usePressInteractions.ts +++ b/src/web/usePressInteractions.ts @@ -1,19 +1,19 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, type RefObject } from 'react'; import type { OnLinkPressEvent, OnMentionPressEvent } from '../types'; +type OnLinkPressEventRef = RefObject< + ((event: OnLinkPressEvent) => void) | undefined +>; + +type OnMentionPressEventRef = RefObject< + ((event: OnMentionPressEvent) => void) | undefined +>; + export function usePressInteractions( containerRef: React.RefObject, - onLinkPress?: (event: OnLinkPressEvent) => void, - onMentionPress?: (event: OnMentionPressEvent) => void + onLinkPressRef: OnLinkPressEventRef, + onMentionPressRef: OnMentionPressEventRef ) { - const linkPressRef = useRef(onLinkPress); - const mentionPressRef = useRef(onMentionPress); - - useEffect(() => { - linkPressRef.current = onLinkPress; - mentionPressRef.current = onMentionPress; - }, [onLinkPress, onMentionPress]); - useEffect(() => { const container = containerRef.current; if (!container) return; @@ -29,15 +29,15 @@ export function usePressInteractions( const anchor = target.closest('a'); if (anchor && container.contains(anchor)) { const url = anchor.getAttribute('href'); - if (url && linkPressRef.current) { + if (url && onLinkPressRef.current) { e.preventDefault(); - linkPressRef.current({ url }); + onLinkPressRef.current({ url }); } } const mention = target.closest('mention'); if (mention && container.contains(mention)) { - if (mentionPressRef.current) { + if (onMentionPressRef.current) { e.preventDefault(); const customAttributes: Record = {}; @@ -47,7 +47,7 @@ export function usePressInteractions( } } - mentionPressRef.current({ + onMentionPressRef.current({ text: mention.getAttribute('text') ?? '', indicator: mention.getAttribute('indicator') ?? '', attributes: customAttributes, @@ -58,5 +58,5 @@ export function usePressInteractions( container.addEventListener('click', handleInteraction); return () => container.removeEventListener('click', handleInteraction); - }, [containerRef]); + }, [containerRef, onLinkPressRef, onMentionPressRef]); } From 845dfab0f43e461484889a046b8b5db44a682123 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 10:30:12 +0200 Subject: [PATCH 07/12] fix: remove obsolete tests --- .playwright/tests/enrichedTextPress.spec.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.playwright/tests/enrichedTextPress.spec.ts b/.playwright/tests/enrichedTextPress.spec.ts index 51292d18..466cd42d 100644 --- a/.playwright/tests/enrichedTextPress.spec.ts +++ b/.playwright/tests/enrichedTextPress.spec.ts @@ -108,26 +108,6 @@ test.describe('EnrichedText mention press', () => { .toEqual({ text: '@John Doe', indicator: '@', attributes: {} }); }); - test('pressing a mention exposes its custom attributes', async ({ page }) => { - await gotoTestEnrichedText(page); - await setEnrichedTextValue( - page, - '

    Channel #general here.

    ' - ); - - await page.locator(`${sel.displayInner} mention`).click(); - - await expect - .poll(async () => - JSON.parse((await mentionPress(page).textContent()) || 'null') - ) - .toEqual({ - text: 'general', - indicator: '#', - attributes: { id: '42', custom: 'custom-attribute', type: 'channel' }, - }); - }); - test('pressing text styled inside a mention still emits onMentionPress', async ({ page, }) => { From ba129cc4433bebd3213fb7e9ce927732a1b52c7b Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 11:41:15 +0200 Subject: [PATCH 08/12] docs: docs update --- docs/TEXT_API_REFERENCE.md | 12 ++++++------ docs/WEB.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/TEXT_API_REFERENCE.md b/docs/TEXT_API_REFERENCE.md index 145ddd23..5a1f7c4b 100644 --- a/docs/TEXT_API_REFERENCE.md +++ b/docs/TEXT_API_REFERENCE.md @@ -94,9 +94,9 @@ interface OnLinkPressEvent { } ``` -| Type | Default Value | Platform | -| ----------------------------------- | ------------- | ------------ | -| `(event: OnLinkPressEvent) => void` | - | iOS, Android | +| Type | Default Value | Platform | +| ----------------------------------- | ------------- | ----------------- | +| `(event: OnLinkPressEvent) => void` | - | iOS, Android, Web | ### `onMentionPress` @@ -110,9 +110,9 @@ interface OnMentionPressEvent { } ``` -| Type | Default Value | Platform | -| -------------------------------------- | ------------- | ------------ | -| `(event: OnMentionPressEvent) => void` | - | iOS, Android | +| Type | Default Value | Platform | +| -------------------------------------- | ------------- | ----------------- | +| `(event: OnMentionPressEvent) => void` | - | iOS, Android, Web | ## EnrichedTextHtmlStyle type diff --git a/docs/WEB.md b/docs/WEB.md index 0d729a6d..838594bf 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -38,6 +38,7 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo ### What works - Customizing the styling using props: `style`, `htmlStyle`, `selectionColor`. +- `onLinkPress` and `onMentionPress` callbacks ### Unsupported @@ -45,7 +46,6 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo - **`useHtmlNormalizer`**: ignored on web. - **`ellipsizeMode`**: ignored on web. - **`numberOfLines`**: ignored on web. -- **Press events**: `onLinkPress` and `onMentionPress` callbacks are ignored on web. ## HTML sanitization From 1f5bfc498a2dc13c45e1b1a0c41c70280134fd12 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 13:20:26 +0200 Subject: [PATCH 09/12] feat: cursor pointer --- apps/example-web/src/App.tsx | 4 ++-- src/web/styleConversion/buildMentionRulesCSS.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index ca814cbe..fdbf47f2 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -176,11 +176,11 @@ function App() { }; const handleLinkPress = (e: OnLinkPressEvent) => { - console.log('[EnrichedTextInput] link press event', e); + console.log('[EnrichedText] link press event', e); }; const handleMentionPress = (e: OnMentionPressEvent) => { - console.log('[EnrichedTextInput] link mention event', e); + console.log('[EnrichedText] mention press event', e); }; const openLinkModal = () => { diff --git a/src/web/styleConversion/buildMentionRulesCSS.ts b/src/web/styleConversion/buildMentionRulesCSS.ts index 9cc06611..8b666d2b 100644 --- a/src/web/styleConversion/buildMentionRulesCSS.ts +++ b/src/web/styleConversion/buildMentionRulesCSS.ts @@ -62,6 +62,12 @@ ${textSelector} { } // Press-state styling - only the read-only EnrichedText handles presses. + lines.push( + `${mentionSelector(ENRICHED_TEXT_CLASSNAME, MENTION_STYLE_DEFAULT_KEY)} { + cursor: pointer; +}`.trim() + ); + for (const indicator of keys) { const textSelector = mentionSelector(ENRICHED_TEXT_CLASSNAME, indicator); From c22282146a1e47339e25cd084df90f8b4d37db41 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 13:47:13 +0200 Subject: [PATCH 10/12] fix: always prevent default link press behavior --- src/web/usePressInteractions.ts | 4 ++-- src/web/usePressInteractions.tsx | 22 ---------------------- 2 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 src/web/usePressInteractions.tsx diff --git a/src/web/usePressInteractions.ts b/src/web/usePressInteractions.ts index 3c158496..5b36bb9f 100644 --- a/src/web/usePressInteractions.ts +++ b/src/web/usePressInteractions.ts @@ -10,7 +10,7 @@ type OnMentionPressEventRef = RefObject< >; export function usePressInteractions( - containerRef: React.RefObject, + containerRef: RefObject, onLinkPressRef: OnLinkPressEventRef, onMentionPressRef: OnMentionPressEventRef ) { @@ -28,9 +28,9 @@ export function usePressInteractions( const anchor = target.closest('a'); if (anchor && container.contains(anchor)) { + e.preventDefault(); const url = anchor.getAttribute('href'); if (url && onLinkPressRef.current) { - e.preventDefault(); onLinkPressRef.current({ url }); } } diff --git a/src/web/usePressInteractions.tsx b/src/web/usePressInteractions.tsx deleted file mode 100644 index 1b9c0e87..00000000 --- a/src/web/usePressInteractions.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from 'react'; - -export function usePressInteractions( - containerRef: React.RefObject -) { - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - - const anchor = target.closest('a'); - if (anchor && container.contains(anchor)) { - e.preventDefault(); - } - }; - - container.addEventListener('click', handleClick); - return () => container.removeEventListener('click', handleClick); - }, [containerRef]); -} From 42a34978aeb1fcec5f56322dacf86731f3ef01b9 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz <146986839+hejsztynx@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:12:27 +0200 Subject: [PATCH 11/12] refactor: onLinkPress onMentionPress props in TestEnrichedText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Igor Furgała <74370735+exploIF@users.noreply.github.com> --- apps/example-web/src/testScreens/TestEnrichedText.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/example-web/src/testScreens/TestEnrichedText.tsx b/apps/example-web/src/testScreens/TestEnrichedText.tsx index accf6c36..4a0a37fe 100644 --- a/apps/example-web/src/testScreens/TestEnrichedText.tsx +++ b/apps/example-web/src/testScreens/TestEnrichedText.tsx @@ -27,12 +27,8 @@ export function TestEnrichedText() { { - setLastLinkPress(event); - }} - onMentionPress={(event) => { - setLastMentionPress(event); - }} + onLinkPress={setLastLinkPress} + onMentionPress={setLastMentionPress} > {value} From 66027db1213ed7022e2cb0bac20474a004c69863 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 7 Jul 2026 21:35:00 +0200 Subject: [PATCH 12/12] feat: press transition / mention cursor --- src/web/EnrichedText.css | 1 + src/web/EnrichedText.tsx | 4 ++-- .../styleConversion/buildMentionRulesCSS.ts | 13 +++++++++---- .../styleConversion/htmlStyleToCSSVariables.ts | 18 ++++++------------ src/web/usePressInteractions.ts | 2 -- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 7c1a1a09..b309846f 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -116,6 +116,7 @@ .et-view a { color: var(--et-link-color); text-decoration-line: var(--et-link-text-decoration-line); + transition: none; } .et-view a:active { diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 75bd7268..f0eb8249 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -56,8 +56,8 @@ export const EnrichedText = memo( ); const mentionRulesCSS = useMemo( - () => buildMentionRulesCSS(resolvedHtmlStyle), - [resolvedHtmlStyle] + () => buildMentionRulesCSS(resolvedHtmlStyle, !!onMentionPress), + [resolvedHtmlStyle, onMentionPress] ); const finalStyle = useMemo( diff --git a/src/web/styleConversion/buildMentionRulesCSS.ts b/src/web/styleConversion/buildMentionRulesCSS.ts index 8b666d2b..b6785c17 100644 --- a/src/web/styleConversion/buildMentionRulesCSS.ts +++ b/src/web/styleConversion/buildMentionRulesCSS.ts @@ -28,7 +28,8 @@ function mentionSelector(className: string, indicator: string): string { } export function buildMentionRulesCSS( - htmlStyle?: HtmlStyle | EnrichedTextHtmlStyle + htmlStyle?: HtmlStyle | EnrichedTextHtmlStyle, + isInteractible?: boolean ): string { const mapRaw = htmlStyle?.mention; if (!mapRaw || typeof mapRaw !== 'object' || !isMentionStyleRecord(mapRaw)) { @@ -57,16 +58,20 @@ ${textSelector} { color: var(${ET_MENTION_CSS_VARS.color(indicator)}, var(${ET_MENTION_CSS_VARS.color(MENTION_STYLE_DEFAULT_KEY)})); background-color: var(${ET_MENTION_CSS_VARS.backgroundColor(indicator)}, var(${ET_MENTION_CSS_VARS.backgroundColor(MENTION_STYLE_DEFAULT_KEY)})); text-decoration-line: var(${ET_MENTION_CSS_VARS.textDecorationLine(indicator)}, var(${ET_MENTION_CSS_VARS.textDecorationLine(MENTION_STYLE_DEFAULT_KEY)})); + transition: none; }`.trim() ); } // Press-state styling - only the read-only EnrichedText handles presses. - lines.push( - `${mentionSelector(ENRICHED_TEXT_CLASSNAME, MENTION_STYLE_DEFAULT_KEY)} { + + if (isInteractible) { + lines.push( + `${mentionSelector(ENRICHED_TEXT_CLASSNAME, MENTION_STYLE_DEFAULT_KEY)} { cursor: pointer; }`.trim() - ); + ); + } for (const indicator of keys) { const textSelector = mentionSelector(ENRICHED_TEXT_CLASSNAME, indicator); diff --git a/src/web/styleConversion/htmlStyleToCSSVariables.ts b/src/web/styleConversion/htmlStyleToCSSVariables.ts index eb39f208..f88de389 100644 --- a/src/web/styleConversion/htmlStyleToCSSVariables.ts +++ b/src/web/styleConversion/htmlStyleToCSSVariables.ts @@ -305,34 +305,28 @@ function expandVarsWithEnrichedTextMention( vars: Record, mention?: EnrichedTextHtmlStyle['mention'] ): void { - const mentionIndicators = isMentionStyleRecord(mention) - ? Object.keys(mention) - : []; + const isStyleRecord = isMentionStyleRecord(mention); + + const mentionIndicators = isStyleRecord ? Object.keys(mention) : []; if (!mentionIndicators.includes(MENTION_STYLE_DEFAULT_KEY)) mentionIndicators.push(MENTION_STYLE_DEFAULT_KEY); for (const indicator of mentionIndicators) { - const style = isMentionStyleRecord(mention) - ? mention?.[indicator] - : mention; + const style = isStyleRecord ? mention?.[indicator] : mention; setColorVar( vars, ET_MENTION_PRESS_CSS_VARS.pressColor(indicator), style?.pressColor ?? - (isMentionStyleRecord(mention) - ? mention.default?.pressColor - : undefined) ?? + (isStyleRecord ? mention.default?.pressColor : undefined) ?? DEFAULT_MENTION_PRESS.pressColor ); setColorVar( vars, ET_MENTION_PRESS_CSS_VARS.pressBackgroundColor(indicator), style?.pressBackgroundColor ?? - (isMentionStyleRecord(mention) - ? mention.default?.pressBackgroundColor - : undefined) ?? + (isStyleRecord ? mention.default?.pressBackgroundColor : undefined) ?? DEFAULT_MENTION_PRESS.pressBackgroundColor ); } diff --git a/src/web/usePressInteractions.ts b/src/web/usePressInteractions.ts index 5b36bb9f..9768b442 100644 --- a/src/web/usePressInteractions.ts +++ b/src/web/usePressInteractions.ts @@ -38,8 +38,6 @@ export function usePressInteractions( const mention = target.closest('mention'); if (mention && container.contains(mention)) { if (onMentionPressRef.current) { - e.preventDefault(); - const customAttributes: Record = {}; for (const attr of Array.from(mention.attributes)) { if (attr.name !== 'text' && attr.name !== 'indicator') {