From 27cac3c5bde8700a4cf7d52182f8ba9e98079745 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Wed, 17 Jun 2026 12:45:45 +0200 Subject: [PATCH 01/15] feat: base --- apps/example-web/src/EnrichedTextApp.css | 12 ++++++++++++ apps/example-web/src/EnrichedTextApp.tsx | 23 +++++++++++++++++++++++ apps/example-web/src/RouteSelector.tsx | 5 +++++ src/index.tsx | 1 + src/web/EnrichedText.tsx | 5 +++++ 5 files changed, 46 insertions(+) create mode 100644 apps/example-web/src/EnrichedTextApp.css create mode 100644 apps/example-web/src/EnrichedTextApp.tsx create mode 100644 src/web/EnrichedText.tsx diff --git a/apps/example-web/src/EnrichedTextApp.css b/apps/example-web/src/EnrichedTextApp.css new file mode 100644 index 00000000..58747e8f --- /dev/null +++ b/apps/example-web/src/EnrichedTextApp.css @@ -0,0 +1,12 @@ +.enriched-text-surface { + width: 100%; + align-self: stretch; + margin: 12px 0; + padding: 16px 18px; + background-color: var(--editor-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 18px; + line-height: 1.5; + word-break: break-word; +} diff --git a/apps/example-web/src/EnrichedTextApp.tsx b/apps/example-web/src/EnrichedTextApp.tsx new file mode 100644 index 00000000..1d60873d --- /dev/null +++ b/apps/example-web/src/EnrichedTextApp.tsx @@ -0,0 +1,23 @@ +import { EnrichedText } from 'react-native-enriched-html'; +import './App.css'; +import './EnrichedTextApp.css'; + +const SAMPLE_HTML = + '

Enriched Text

' + + '

This is bold italic both

' + + '

Click a link

' + + ''; + +function EnrichedTextApp() { + return ( +
+

Enriched Text

+ +
+ {SAMPLE_HTML} +
+
+ ); +} + +export default EnrichedTextApp; diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index d04f3d2c..98e98739 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -5,6 +5,7 @@ import { TestSetSelection } from './testScreens/TestSetSelection'; import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; import { useEffect, useState } from 'react'; +import EnrichedTextApp from './EnrichedTextApp'; export default function RouteSelector() { const [path, setPath] = useState(window.location.pathname); @@ -40,5 +41,9 @@ export default function RouteSelector() { return ; } + if (path === '/text') { + return ; + } + return ; } diff --git a/src/index.tsx b/src/index.tsx index 47256672..59d9054f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,5 @@ export { EnrichedTextInput } from './web/EnrichedTextInput'; +export { EnrichedText } from './web/EnrichedText'; export type { EnrichedInputStyle, EnrichedTextInputProps, diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx new file mode 100644 index 00000000..85364f89 --- /dev/null +++ b/src/web/EnrichedText.tsx @@ -0,0 +1,5 @@ +import type { EnrichedTextProps } from '../types'; + +export const EnrichedText = ({ children }: EnrichedTextProps) => { + return
; +}; From 8a79e289adf8597e262ad77a34bd27ee3f0516f2 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Wed, 17 Jun 2026 15:48:27 +0200 Subject: [PATCH 02/15] feat: style prop and theming implementation --- apps/example-web/src/EnrichedTextApp.css | 12 - apps/example-web/src/EnrichedTextApp.tsx | 18 +- src/web/EnrichedText.css | 3 + src/web/EnrichedText.tsx | 35 ++- ... enrichedBaseStyleToCSSProperties.test.ts} | 8 +- .../enrichedTextStyleToCSSProperties.test.ts | 208 ++++++++++++++++ .../enrichedBaseStyleToCSSProperties.ts | 228 ++++++++++++++++++ .../enrichedInputStyleToCSSProperties.ts | 223 +---------------- .../enrichedTextStyleToCSSProperties.ts | 58 +++++ .../enrichedTextThemingToCSSProperties.ts | 22 ++ 10 files changed, 575 insertions(+), 240 deletions(-) delete mode 100644 apps/example-web/src/EnrichedTextApp.css create mode 100644 src/web/EnrichedText.css rename src/web/styleConversion/__tests__/{enrichedInputStyleToCSSProperties.test.ts => enrichedBaseStyleToCSSProperties.test.ts} (98%) create mode 100644 src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts create mode 100644 src/web/styleConversion/enrichedBaseStyleToCSSProperties.ts create mode 100644 src/web/styleConversion/enrichedTextStyleToCSSProperties.ts create mode 100644 src/web/styleConversion/enrichedTextThemingToCSSProperties.ts diff --git a/apps/example-web/src/EnrichedTextApp.css b/apps/example-web/src/EnrichedTextApp.css deleted file mode 100644 index 58747e8f..00000000 --- a/apps/example-web/src/EnrichedTextApp.css +++ /dev/null @@ -1,12 +0,0 @@ -.enriched-text-surface { - width: 100%; - align-self: stretch; - margin: 12px 0; - padding: 16px 18px; - background-color: var(--editor-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - font-size: 18px; - line-height: 1.5; - word-break: break-word; -} diff --git a/apps/example-web/src/EnrichedTextApp.tsx b/apps/example-web/src/EnrichedTextApp.tsx index 1d60873d..afd9fec8 100644 --- a/apps/example-web/src/EnrichedTextApp.tsx +++ b/apps/example-web/src/EnrichedTextApp.tsx @@ -1,6 +1,6 @@ import { EnrichedText } from 'react-native-enriched-html'; import './App.css'; -import './EnrichedTextApp.css'; +import type { TextStyle } from 'react-native'; const SAMPLE_HTML = '

Enriched Text

' + @@ -12,12 +12,20 @@ function EnrichedTextApp() { return (

Enriched Text

- -
- {SAMPLE_HTML} -
+ {SAMPLE_HTML}
); } +const enrichedTextStyle: TextStyle = { + backgroundColor: 'gainsboro', + width: '100%', + marginVertical: 12, + maxHeight: 300, + paddingVertical: 12, + paddingHorizontal: 14, + borderRadius: 8, + fontSize: 18, +}; + export default EnrichedTextApp; diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css new file mode 100644 index 00000000..67166bf5 --- /dev/null +++ b/src/web/EnrichedText.css @@ -0,0 +1,3 @@ +.et ::selection { + background-color: var(--et-selection-color, Highlight); +} diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 85364f89..a5edec2d 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -1,5 +1,36 @@ +import { useMemo, type CSSProperties } from 'react'; import type { EnrichedTextProps } from '../types'; +import './EnrichedText.css'; +import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties'; +import { enrichedTextThemingToCSSProperties } from './styleConversion/enrichedTextThemingToCSSProperties'; -export const EnrichedText = ({ children }: EnrichedTextProps) => { - return
; +export const EnrichedText = ({ + children, + style, + selectionColor, +}: EnrichedTextProps) => { + const textStyle: CSSProperties = useMemo( + () => enrichedTextStyleToCSSProperties(style ?? {}), + [style] + ); + + const themingStyle = useMemo( + () => enrichedTextThemingToCSSProperties({ selectionColor }), + [selectionColor] + ); + + const finalStyle = useMemo( + () => ({ ...textStyle, ...themingStyle }), + [textStyle, themingStyle] + ); + + return ( + <> +
+ + ); }; diff --git a/src/web/styleConversion/__tests__/enrichedInputStyleToCSSProperties.test.ts b/src/web/styleConversion/__tests__/enrichedBaseStyleToCSSProperties.test.ts similarity index 98% rename from src/web/styleConversion/__tests__/enrichedInputStyleToCSSProperties.test.ts rename to src/web/styleConversion/__tests__/enrichedBaseStyleToCSSProperties.test.ts index 41a5ea23..d02ca12e 100644 --- a/src/web/styleConversion/__tests__/enrichedInputStyleToCSSProperties.test.ts +++ b/src/web/styleConversion/__tests__/enrichedBaseStyleToCSSProperties.test.ts @@ -1,6 +1,6 @@ import type { CSSProperties } from 'react'; import type { EnrichedInputStyle } from '../../../types'; -import { enrichedInputStyleToCSSProperties } from '../enrichedInputStyleToCSSProperties'; +import { enrichedBaseStyleToCSSProperties } from '../enrichedBaseStyleToCSSProperties'; type TestCase = { description: string; @@ -9,7 +9,7 @@ type TestCase = { }; function convert(style: EnrichedInputStyle): CSSProperties { - return enrichedInputStyleToCSSProperties(style); + return enrichedBaseStyleToCSSProperties(style); } describe('empty input', () => { @@ -931,7 +931,7 @@ describe('extraOptions.scrollEnabled', () => { it('sets overflowY to auto when scrollEnabled is true', () => { expect(convert({})).not.toHaveProperty('overflowY'); expect( - enrichedInputStyleToCSSProperties({}, { scrollEnabled: true }) + enrichedBaseStyleToCSSProperties({}, { scrollEnabled: true }) ).toMatchObject({ overflowY: 'auto', }); @@ -939,7 +939,7 @@ describe('extraOptions.scrollEnabled', () => { it('sets overflowY to hidden when scrollEnabled is false', () => { expect( - enrichedInputStyleToCSSProperties({}, { scrollEnabled: false }) + enrichedBaseStyleToCSSProperties({}, { scrollEnabled: false }) ).toMatchObject({ overflowY: 'hidden', }); diff --git a/src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts b/src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts new file mode 100644 index 00000000..adf1b60e --- /dev/null +++ b/src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts @@ -0,0 +1,208 @@ +import type { CSSProperties } from 'react'; +import type { TextStyle } from 'react-native'; +import { enrichedTextStyleToCSSProperties } from '../enrichedTextStyleToCSSProperties'; + +type TestCase = { + description: string; + input: TextStyle; + expected: CSSProperties; +}; + +function convert(style: TextStyle): CSSProperties { + return enrichedTextStyleToCSSProperties(style); +} + +// These suites only cover the text-only properties that +// enrichedTextStyleToCSSProperties adds on top of the shared base. +// The shared (ViewStyle) conversions are covered by the base function's tests. + +describe('empty input', () => { + it('returns an empty object for an empty style', () => { + expect(convert({})).toEqual({}); + }); +}); + +describe('textAlign', () => { + const cases: TestCase[] = [ + { + description: 'left passes through', + input: { textAlign: 'left' }, + expected: { textAlign: 'left' }, + }, + { + description: 'center passes through', + input: { textAlign: 'center' }, + expected: { textAlign: 'center' }, + }, + { + description: 'justify passes through', + input: { textAlign: 'justify' }, + expected: { textAlign: 'justify' }, + }, + ]; + + it.each(cases)('$description', ({ input, expected }) => { + expect(convert(input)).toEqual(expected); + }); + + it("omits 'auto' (no CSS equivalent)", () => { + expect(convert({ textAlign: 'auto' })).not.toHaveProperty('textAlign'); + }); +}); + +describe('textTransform', () => { + it.each(['none', 'capitalize', 'uppercase', 'lowercase'] as const)( + '%s passes through', + (value) => { + expect(convert({ textTransform: value })).toEqual({ + textTransform: value, + }); + } + ); +}); + +describe('text decoration', () => { + const cases: TestCase[] = [ + { + description: 'textDecorationLine underline passes through', + input: { textDecorationLine: 'underline' }, + expected: { textDecorationLine: 'underline' }, + }, + { + description: 'textDecorationLine combined value passes through', + input: { textDecorationLine: 'underline line-through' }, + expected: { textDecorationLine: 'underline line-through' }, + }, + { + description: 'textDecorationStyle passes through', + input: { textDecorationStyle: 'dashed' }, + expected: { textDecorationStyle: 'dashed' }, + }, + { + description: 'textDecorationColor (string) passes through', + input: { textDecorationColor: 'red' }, + expected: { textDecorationColor: 'red' }, + }, + { + description: 'textDecorationColor (integer) converts to rgba', + input: { textDecorationColor: 0xff0000ff } as unknown as TextStyle, + expected: { textDecorationColor: 'rgba(255, 0, 0, 1)' }, + }, + ]; + + it.each(cases)('$description', ({ input, expected }) => { + expect(convert(input)).toEqual(expected); + }); +}); + +describe('textShadow', () => { + it('combines offset, radius and color into the CSS shorthand', () => { + expect( + convert({ + textShadowColor: 'black', + textShadowOffset: { width: 1, height: 2 }, + textShadowRadius: 3, + }) + ).toEqual({ textShadow: '1px 2px 3px black' }); + }); + + it('defaults missing offset and radius to 0', () => { + expect(convert({ textShadowColor: 'red' })).toEqual({ + textShadow: '0px 0px 0px red', + }); + }); + + it('omits the color when none is provided', () => { + expect( + convert({ + textShadowOffset: { width: 4, height: 5 }, + textShadowRadius: 6, + }) + ).toEqual({ textShadow: '4px 5px 6px' }); + }); + + it('converts integer color values', () => { + expect( + convert({ + textShadowColor: 0x00000080, + textShadowOffset: { width: 0, height: 1 }, + } as unknown as TextStyle) + ).toEqual({ textShadow: `0px 1px 0px rgba(0, 0, 0, ${128 / 255})` }); + }); + + it('is omitted when no textShadow props are set', () => { + expect(convert({ color: 'red' })).not.toHaveProperty('textShadow'); + }); +}); + +describe('userSelect', () => { + it.each(['auto', 'none', 'text', 'contain', 'all'] as const)( + '%s passes through', + (value) => { + expect(convert({ userSelect: value })).toEqual({ userSelect: value }); + } + ); +}); + +describe('fontVariant', () => { + it('joins the RN array into a CSS string', () => { + expect(convert({ fontVariant: ['small-caps', 'tabular-nums'] })).toEqual({ + fontVariant: 'small-caps tabular-nums', + }); + }); + + it('joins a single-item array', () => { + expect(convert({ fontVariant: ['small-caps'] })).toEqual({ + fontVariant: 'small-caps', + }); + }); +}); + +describe('writingDirection → direction', () => { + it.each(['ltr', 'rtl'] as const)('%s maps to direction', (value) => { + expect(convert({ writingDirection: value })).toEqual({ direction: value }); + }); + + it("omits 'auto' (no CSS equivalent)", () => { + expect(convert({ writingDirection: 'auto' })).not.toHaveProperty( + 'direction' + ); + }); +}); + +describe('verticalAlign', () => { + it.each(['top', 'bottom', 'middle'] as const)( + '%s passes through', + (value) => { + expect(convert({ verticalAlign: value })).toEqual({ + verticalAlign: value, + }); + } + ); + + it("omits 'auto' (no CSS equivalent)", () => { + expect(convert({ verticalAlign: 'auto' })).not.toHaveProperty( + 'verticalAlign' + ); + }); +}); + +describe('combined text style', () => { + it('emits all text-only properties together', () => { + expect( + convert({ + textAlign: 'center', + textTransform: 'uppercase', + textDecorationLine: 'underline', + textDecorationColor: 'blue', + userSelect: 'none', + }) + ).toEqual({ + textAlign: 'center', + textTransform: 'uppercase', + textDecorationLine: 'underline', + textDecorationColor: 'blue', + userSelect: 'none', + }); + }); +}); diff --git a/src/web/styleConversion/enrichedBaseStyleToCSSProperties.ts b/src/web/styleConversion/enrichedBaseStyleToCSSProperties.ts new file mode 100644 index 00000000..d2fd66b7 --- /dev/null +++ b/src/web/styleConversion/enrichedBaseStyleToCSSProperties.ts @@ -0,0 +1,228 @@ +import type { CSSProperties } from 'react'; +import type { EnrichedInputStyle } from '../../types'; +import type { + AnimatableNumericValue, + DimensionValue, + TextStyle, +} from 'react-native'; +import { toColor } from './toColor'; + +export interface StyleConversionExtraOptions { + scrollEnabled?: boolean; +} + +export function enrichedBaseStyleToCSSProperties( + style: EnrichedInputStyle | TextStyle, + extraOptions: StyleConversionExtraOptions = {} +): CSSProperties { + const css: CSSProperties = { + // Dimensions + width: toPx(style.width), + height: toPx(style.height), + minWidth: toPx(style.minWidth), + maxWidth: toPx(style.maxWidth), + minHeight: toPx(style.minHeight), + maxHeight: toPx(style.maxHeight), + top: toPx(style.top), + bottom: toPx(style.bottom), + left: toPx(style.left), + right: toPx(style.right), + inset: toPx(style.inset), + insetBlock: toPx(style.insetBlock), + insetBlockEnd: toPx(style.insetBlockEnd), + insetBlockStart: toPx(style.insetBlockStart), + insetInline: toPx(style.insetInline), + insetInlineEnd: toPx(style.insetInlineEnd ?? style.end), + insetInlineStart: toPx(style.insetInlineStart ?? style.start), + + // Margin - specific properties take precedence over shorthands (RN behavior) + margin: toPx(style.margin), + marginTop: toPx(style.marginTop ?? style.marginVertical), + marginBottom: toPx(style.marginBottom ?? style.marginVertical), + marginLeft: toPx(style.marginLeft ?? style.marginHorizontal), + marginRight: toPx(style.marginRight ?? style.marginHorizontal), + marginBlock: toPx(style.marginBlock), + marginBlockEnd: toPx(style.marginBlockEnd), + marginBlockStart: toPx(style.marginBlockStart), + marginInline: toPx(style.marginInline), + marginInlineEnd: toPx(style.marginInlineEnd ?? style.marginEnd), + marginInlineStart: toPx(style.marginInlineStart ?? style.marginStart), + + // Padding - specific properties take precedence over shorthands (RN behavior) + padding: toPx(style.padding), + paddingTop: toPx(style.paddingTop ?? style.paddingVertical), + paddingBottom: toPx(style.paddingBottom ?? style.paddingVertical), + paddingLeft: toPx(style.paddingLeft ?? style.paddingHorizontal), + paddingRight: toPx(style.paddingRight ?? style.paddingHorizontal), + paddingBlock: toPx(style.paddingBlock), + paddingBlockEnd: toPx(style.paddingBlockEnd), + paddingBlockStart: toPx(style.paddingBlockStart), + paddingInline: toPx(style.paddingInline), + paddingInlineEnd: toPx(style.paddingInlineEnd ?? style.paddingEnd), + paddingInlineStart: toPx(style.paddingInlineStart ?? style.paddingStart), + + // Border widths + borderInlineStartWidth: toPx(style.borderStartWidth), + borderInlineEndWidth: toPx(style.borderEndWidth), + borderWidth: toPx(style.borderWidth), + borderTopWidth: toPx(style.borderTopWidth), + borderBottomWidth: toPx(style.borderBottomWidth), + borderLeftWidth: toPx(style.borderLeftWidth), + borderRightWidth: toPx(style.borderRightWidth), + + // Border radius (physical) + borderRadius: toPx(style.borderRadius), + borderTopLeftRadius: toPx(style.borderTopLeftRadius), + borderTopRightRadius: toPx(style.borderTopRightRadius), + borderBottomLeftRadius: toPx(style.borderBottomLeftRadius), + borderBottomRightRadius: toPx(style.borderBottomRightRadius), + + // Border radius (logical) + borderStartStartRadius: toPx( + style.borderStartStartRadius ?? style.borderTopStartRadius + ), + borderStartEndRadius: toPx( + style.borderStartEndRadius ?? style.borderTopEndRadius + ), + borderEndStartRadius: toPx( + style.borderEndStartRadius ?? style.borderBottomStartRadius + ), + borderEndEndRadius: toPx( + style.borderEndEndRadius ?? style.borderBottomEndRadius + ), + + // Border colors + borderColor: toColor(style.borderColor), + borderBlockColor: toColor(style.borderBlockColor), + borderBlockEndColor: toColor(style.borderBlockEndColor), + borderBlockStartColor: toColor(style.borderBlockStartColor), + borderBottomColor: toColor(style.borderBottomColor), + borderInlineEndColor: toColor(style.borderEndColor), + borderLeftColor: toColor(style.borderLeftColor), + borderRightColor: toColor(style.borderRightColor), + borderInlineStartColor: toColor(style.borderStartColor), + borderTopColor: toColor(style.borderTopColor), + borderStyle: + style.borderStyle ?? + (style.borderWidth != null || + style.borderTopWidth != null || + style.borderBottomWidth != null || + style.borderLeftWidth != null || + style.borderRightWidth != null || + style.borderStartWidth != null || + style.borderEndWidth != null || + style.borderColor != null + ? 'solid' + : undefined), + + // Typography + color: toColor(style.color), + fontFamily: style.fontFamily, + fontSize: toPx(style.fontSize), + fontStyle: style.fontStyle, + fontWeight: style.fontWeight, + lineHeight: toPx(style.lineHeight), + letterSpacing: toPx(style.letterSpacing), + + // View appearance + backgroundColor: toColor(style.backgroundColor), + // boxShadow/filter: RN accepts arrays, CSS only strings + boxShadow: + typeof style.boxShadow === 'string' ? style.boxShadow : undefined, + display: style.display, + position: style.position, + alignSelf: style.alignSelf, + backfaceVisibility: style.backfaceVisibility, + cursor: style.cursor, + filter: typeof style.filter === 'string' ? style.filter : undefined, + mixBlendMode: style.mixBlendMode, + boxSizing: style.boxSizing, + // pointerEvents: RN 'box-none'/'box-only' have no CSS equivalent + pointerEvents: + style.pointerEvents === 'auto' || style.pointerEvents === 'none' + ? style.pointerEvents + : undefined, + + // Outline + outlineColor: toColor(style.outlineColor), + outlineStyle: + style.outlineStyle ?? (style.outlineWidth != null ? 'solid' : undefined), + outlineOffset: toPx(style.outlineOffset), + outlineWidth: toPx(style.outlineWidth), + + // Transforms + transform: resolveTransform(style.transform), + // transformOrigin: RN accepts strings or arrays, CSS only strings + transformOrigin: + typeof style.transformOrigin === 'string' + ? style.transformOrigin + : undefined, + + // Flex + flex: style.flex, + flexGrow: style.flexGrow, + flexShrink: style.flexShrink, + flexBasis: toPx(style.flexBasis), + + // Misc + zIndex: style.zIndex, + // opacity: RN AnimatableNumericValue includes AnimatedNode; CSS only number + opacity: typeof style.opacity === 'number' ? style.opacity : undefined, + aspectRatio: style.aspectRatio, + + // Extra options + overflowY: + extraOptions.scrollEnabled != null + ? extraOptions.scrollEnabled + ? 'auto' + : 'hidden' + : undefined, + }; + + // Clean undefined values + return Object.fromEntries( + Object.entries(css).filter(([, v]) => v !== undefined) + ); +} + +function toPx( + value?: DimensionValue | AnimatableNumericValue | string +): string | undefined { + if (value == null) return undefined; + if (typeof value === 'number') return `${value}px`; + if (typeof value === 'string') return value; + return undefined; +} + +function resolveTransform( + transform: EnrichedInputStyle['transform'] +): string | undefined { + if (typeof transform === 'string') return transform; + if (!Array.isArray(transform)) return undefined; + + const parts = transform.map((item) => { + if ('translateX' in item) return `translateX(${item.translateX}px)`; + if ('translateY' in item) return `translateY(${item.translateY}px)`; + if ('translateZ' in item) return `translateZ(${item.translateZ}px)`; + if ('scale' in item) return `scale(${item.scale})`; + if ('scaleX' in item) return `scaleX(${item.scaleX})`; + if ('scaleY' in item) return `scaleY(${item.scaleY})`; + if ('scaleZ' in item) return `scaleZ(${item.scaleZ})`; + if ('rotate' in item) return `rotate(${item.rotate})`; + if ('rotateX' in item) return `rotateX(${item.rotateX})`; + if ('rotateY' in item) return `rotateY(${item.rotateY})`; + if ('rotateZ' in item) return `rotateZ(${item.rotateZ})`; + if ('skewX' in item) return `skewX(${item.skewX})`; + if ('skewY' in item) return `skewY(${item.skewY})`; + if ('perspective' in item) return `perspective(${item.perspective}px)`; + if ('matrix' in item) { + return item.matrix.length === 16 + ? `matrix3d(${item.matrix.join(', ')})` + : `matrix(${item.matrix.join(', ')})`; + } + return null; + }); + + const css = parts.filter(Boolean).join(' '); + return css || undefined; +} diff --git a/src/web/styleConversion/enrichedInputStyleToCSSProperties.ts b/src/web/styleConversion/enrichedInputStyleToCSSProperties.ts index d2dc04c2..93341dfe 100644 --- a/src/web/styleConversion/enrichedInputStyleToCSSProperties.ts +++ b/src/web/styleConversion/enrichedInputStyleToCSSProperties.ts @@ -1,224 +1,13 @@ import type { CSSProperties } from 'react'; import type { EnrichedInputStyle } from '../../types'; -import type { AnimatableNumericValue, DimensionValue } from 'react-native'; -import { toColor } from './toColor'; - -interface ExtraOptions { - scrollEnabled?: boolean; -} +import { + enrichedBaseStyleToCSSProperties, + type StyleConversionExtraOptions, +} from './enrichedBaseStyleToCSSProperties'; export function enrichedInputStyleToCSSProperties( style: EnrichedInputStyle, - extraOptions: ExtraOptions = {} + extraOptions?: StyleConversionExtraOptions ): CSSProperties { - const css: CSSProperties = { - // Dimensions - width: toPx(style.width), - height: toPx(style.height), - minWidth: toPx(style.minWidth), - maxWidth: toPx(style.maxWidth), - minHeight: toPx(style.minHeight), - maxHeight: toPx(style.maxHeight), - top: toPx(style.top), - bottom: toPx(style.bottom), - left: toPx(style.left), - right: toPx(style.right), - inset: toPx(style.inset), - insetBlock: toPx(style.insetBlock), - insetBlockEnd: toPx(style.insetBlockEnd), - insetBlockStart: toPx(style.insetBlockStart), - insetInline: toPx(style.insetInline), - insetInlineEnd: toPx(style.insetInlineEnd ?? style.end), - insetInlineStart: toPx(style.insetInlineStart ?? style.start), - - // Margin - specific properties take precedence over shorthands (RN behavior) - margin: toPx(style.margin), - marginTop: toPx(style.marginTop ?? style.marginVertical), - marginBottom: toPx(style.marginBottom ?? style.marginVertical), - marginLeft: toPx(style.marginLeft ?? style.marginHorizontal), - marginRight: toPx(style.marginRight ?? style.marginHorizontal), - marginBlock: toPx(style.marginBlock), - marginBlockEnd: toPx(style.marginBlockEnd), - marginBlockStart: toPx(style.marginBlockStart), - marginInline: toPx(style.marginInline), - marginInlineEnd: toPx(style.marginInlineEnd ?? style.marginEnd), - marginInlineStart: toPx(style.marginInlineStart ?? style.marginStart), - - // Padding - specific properties take precedence over shorthands (RN behavior) - padding: toPx(style.padding), - paddingTop: toPx(style.paddingTop ?? style.paddingVertical), - paddingBottom: toPx(style.paddingBottom ?? style.paddingVertical), - paddingLeft: toPx(style.paddingLeft ?? style.paddingHorizontal), - paddingRight: toPx(style.paddingRight ?? style.paddingHorizontal), - paddingBlock: toPx(style.paddingBlock), - paddingBlockEnd: toPx(style.paddingBlockEnd), - paddingBlockStart: toPx(style.paddingBlockStart), - paddingInline: toPx(style.paddingInline), - paddingInlineEnd: toPx(style.paddingInlineEnd ?? style.paddingEnd), - paddingInlineStart: toPx(style.paddingInlineStart ?? style.paddingStart), - - // Border widths - borderInlineStartWidth: toPx(style.borderStartWidth), - borderInlineEndWidth: toPx(style.borderEndWidth), - borderWidth: toPx(style.borderWidth), - borderTopWidth: toPx(style.borderTopWidth), - borderBottomWidth: toPx(style.borderBottomWidth), - borderLeftWidth: toPx(style.borderLeftWidth), - borderRightWidth: toPx(style.borderRightWidth), - - // Border radius (physical) - borderRadius: toPx(style.borderRadius), - borderTopLeftRadius: toPx(style.borderTopLeftRadius), - borderTopRightRadius: toPx(style.borderTopRightRadius), - borderBottomLeftRadius: toPx(style.borderBottomLeftRadius), - borderBottomRightRadius: toPx(style.borderBottomRightRadius), - - // Border radius (logical) - borderStartStartRadius: toPx( - style.borderStartStartRadius ?? style.borderTopStartRadius - ), - borderStartEndRadius: toPx( - style.borderStartEndRadius ?? style.borderTopEndRadius - ), - borderEndStartRadius: toPx( - style.borderEndStartRadius ?? style.borderBottomStartRadius - ), - borderEndEndRadius: toPx( - style.borderEndEndRadius ?? style.borderBottomEndRadius - ), - - // Border colors - borderColor: toColor(style.borderColor), - borderBlockColor: toColor(style.borderBlockColor), - borderBlockEndColor: toColor(style.borderBlockEndColor), - borderBlockStartColor: toColor(style.borderBlockStartColor), - borderBottomColor: toColor(style.borderBottomColor), - borderInlineEndColor: toColor(style.borderEndColor), - borderLeftColor: toColor(style.borderLeftColor), - borderRightColor: toColor(style.borderRightColor), - borderInlineStartColor: toColor(style.borderStartColor), - borderTopColor: toColor(style.borderTopColor), - borderStyle: - style.borderStyle ?? - (style.borderWidth != null || - style.borderTopWidth != null || - style.borderBottomWidth != null || - style.borderLeftWidth != null || - style.borderRightWidth != null || - style.borderStartWidth != null || - style.borderEndWidth != null || - style.borderColor != null - ? 'solid' - : undefined), - - // Typography - color: toColor(style.color), - fontFamily: style.fontFamily, - fontSize: toPx(style.fontSize), - fontStyle: style.fontStyle, - fontWeight: style.fontWeight, - lineHeight: toPx(style.lineHeight), - letterSpacing: toPx(style.letterSpacing), - - // View appearance - backgroundColor: toColor(style.backgroundColor), - // boxShadow/filter: RN accepts arrays, CSS only strings - boxShadow: - typeof style.boxShadow === 'string' ? style.boxShadow : undefined, - display: style.display, - position: style.position, - alignSelf: style.alignSelf, - backfaceVisibility: style.backfaceVisibility, - cursor: style.cursor, - filter: typeof style.filter === 'string' ? style.filter : undefined, - mixBlendMode: style.mixBlendMode, - boxSizing: style.boxSizing, - // pointerEvents: RN 'box-none'/'box-only' have no CSS equivalent - pointerEvents: - style.pointerEvents === 'auto' || style.pointerEvents === 'none' - ? style.pointerEvents - : undefined, - - // Outline - outlineColor: toColor(style.outlineColor), - outlineStyle: - style.outlineStyle ?? (style.outlineWidth != null ? 'solid' : undefined), - outlineOffset: toPx(style.outlineOffset), - outlineWidth: toPx(style.outlineWidth), - - // Transforms - transform: resolveTransform(style.transform), - // transformOrigin: RN accepts strings or arrays, CSS only strings - transformOrigin: - typeof style.transformOrigin === 'string' - ? style.transformOrigin - : undefined, - - // Flex - flex: style.flex, - flexGrow: style.flexGrow, - flexShrink: style.flexShrink, - flexBasis: toPx(style.flexBasis), - - // Misc - zIndex: style.zIndex, - // opacity: RN AnimatableNumericValue includes AnimatedNode; CSS only number - opacity: typeof style.opacity === 'number' ? style.opacity : undefined, - aspectRatio: style.aspectRatio, - - // Extra options - overflowY: - extraOptions.scrollEnabled != null - ? extraOptions.scrollEnabled - ? 'auto' - : 'hidden' - : undefined, - }; - - // Clean undefined values - return Object.fromEntries( - Object.entries(css).filter(([, v]) => v !== undefined) - ); -} - -function toPx( - value?: DimensionValue | AnimatableNumericValue | string -): string | undefined { - if (value == null) return undefined; - if (typeof value === 'number') return `${value}px`; - if (typeof value === 'string') return value; - return undefined; -} - -function resolveTransform( - transform: EnrichedInputStyle['transform'] -): string | undefined { - if (typeof transform === 'string') return transform; - if (!Array.isArray(transform)) return undefined; - - const parts = transform.map((item) => { - if ('translateX' in item) return `translateX(${item.translateX}px)`; - if ('translateY' in item) return `translateY(${item.translateY}px)`; - if ('translateZ' in item) return `translateZ(${item.translateZ}px)`; - if ('scale' in item) return `scale(${item.scale})`; - if ('scaleX' in item) return `scaleX(${item.scaleX})`; - if ('scaleY' in item) return `scaleY(${item.scaleY})`; - if ('scaleZ' in item) return `scaleZ(${item.scaleZ})`; - if ('rotate' in item) return `rotate(${item.rotate})`; - if ('rotateX' in item) return `rotateX(${item.rotateX})`; - if ('rotateY' in item) return `rotateY(${item.rotateY})`; - if ('rotateZ' in item) return `rotateZ(${item.rotateZ})`; - if ('skewX' in item) return `skewX(${item.skewX})`; - if ('skewY' in item) return `skewY(${item.skewY})`; - if ('perspective' in item) return `perspective(${item.perspective}px)`; - if ('matrix' in item) { - return item.matrix.length === 16 - ? `matrix3d(${item.matrix.join(', ')})` - : `matrix(${item.matrix.join(', ')})`; - } - return null; - }); - - const css = parts.filter(Boolean).join(' '); - return css || undefined; + return enrichedBaseStyleToCSSProperties(style, extraOptions); } diff --git a/src/web/styleConversion/enrichedTextStyleToCSSProperties.ts b/src/web/styleConversion/enrichedTextStyleToCSSProperties.ts new file mode 100644 index 00000000..365a8496 --- /dev/null +++ b/src/web/styleConversion/enrichedTextStyleToCSSProperties.ts @@ -0,0 +1,58 @@ +import type { CSSProperties } from 'react'; +import type { TextStyle } from 'react-native'; +import { + enrichedBaseStyleToCSSProperties, + type StyleConversionExtraOptions, +} from './enrichedBaseStyleToCSSProperties'; +import { toColor } from './toColor'; + +export function enrichedTextStyleToCSSProperties( + style: TextStyle, + extraOptions?: StyleConversionExtraOptions +): CSSProperties { + const css: CSSProperties = { + ...enrichedBaseStyleToCSSProperties(style, extraOptions), + + // Text-only properties + // textAlign: RN 'auto' has no CSS equivalent + textAlign: style.textAlign !== 'auto' ? style.textAlign : undefined, + textTransform: style.textTransform, + textDecorationLine: style.textDecorationLine, + textDecorationStyle: style.textDecorationStyle, + textDecorationColor: toColor(style.textDecorationColor), + textShadow: resolveTextShadow(style), + userSelect: style.userSelect, + fontVariant: Array.isArray(style.fontVariant) + ? style.fontVariant.join(' ') + : undefined, + // writingDirection: RN 'auto' has no CSS equivalent + direction: + style.writingDirection !== 'auto' ? style.writingDirection : undefined, + // verticalAlign: RN 'auto' has no CSS equivalent + verticalAlign: + style.verticalAlign !== 'auto' ? style.verticalAlign : undefined, + }; + + // Clean undefined values + return Object.fromEntries( + Object.entries(css).filter(([, v]) => v !== undefined) + ); +} + +// RN exposes textShadow* as separate props; CSS uses a single shorthand. +function resolveTextShadow(style: TextStyle): string | undefined { + const { textShadowColor, textShadowOffset, textShadowRadius } = style; + if ( + textShadowColor == null && + textShadowOffset == null && + textShadowRadius == null + ) { + return undefined; + } + const x = textShadowOffset?.width ?? 0; + const y = textShadowOffset?.height ?? 0; + const blur = textShadowRadius ?? 0; + const color = toColor(textShadowColor); + const offset = `${x}px ${y}px ${blur}px`; + return color ? `${offset} ${color}` : offset; +} diff --git a/src/web/styleConversion/enrichedTextThemingToCSSProperties.ts b/src/web/styleConversion/enrichedTextThemingToCSSProperties.ts new file mode 100644 index 00000000..9df1e774 --- /dev/null +++ b/src/web/styleConversion/enrichedTextThemingToCSSProperties.ts @@ -0,0 +1,22 @@ +import type { CSSProperties } from 'react'; +import type { ColorValue } from 'react-native'; +import { toColor } from './toColor'; + +type EnrichedTextThemingStyle = Partial<{ + '--et-selection-color': string; +}>; + +export interface EnrichedTextThemingColors { + selectionColor?: ColorValue; +} + +export function enrichedTextThemingToCSSProperties({ + selectionColor, +}: EnrichedTextThemingColors): CSSProperties { + const extra: EnrichedTextThemingStyle = {}; + + const selectionCss = toColor(selectionColor); + if (selectionCss) extra['--et-selection-color'] = selectionCss; + + return extra as CSSProperties; +} From c7ff13a308a45b55a5a17ac269ba3881e5657bc7 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 18 Jun 2026 13:36:48 +0200 Subject: [PATCH 03/15] feat: impl html style --- apps/example-web/src/App.css | 4 + apps/example-web/src/App.tsx | 45 +++- apps/example-web/src/EnrichedTextApp.tsx | 31 --- apps/example-web/src/RouteSelector.tsx | 5 - src/web/EnrichedText.css | 252 +++++++++++++++++- src/web/EnrichedText.tsx | 25 +- src/web/EnrichedTextInput.css | 201 -------------- src/web/EnrichedTextInput.tsx | 7 +- src/web/consts/classNames.ts | 2 + .../__tests__/buildMentionRulesCSS.test.ts | 54 ++-- ...nrichedInputThemingToCSSProperties.test.ts | 4 +- .../enrichedTextStyleToCSSProperties.test.ts | 75 +++++- .../__tests__/htmlStyleToCSSVariables.test.ts | 76 +++--- .../styleConversion/buildMentionRulesCSS.ts | 26 +- .../enrichedInputThemingToCSSProperties.ts | 8 +- .../enrichedTextStyleToCSSProperties.ts | 10 +- .../enrichedTextThemingToCSSProperties.ts | 22 -- .../htmlStyleToCSSVariables.ts | 116 ++++---- 18 files changed, 545 insertions(+), 418 deletions(-) delete mode 100644 apps/example-web/src/EnrichedTextApp.tsx delete mode 100644 src/web/EnrichedTextInput.css create mode 100644 src/web/consts/classNames.ts delete mode 100644 src/web/styleConversion/enrichedTextThemingToCSSProperties.ts diff --git a/apps/example-web/src/App.css b/apps/example-web/src/App.css index ab8b5de9..cce452ab 100644 --- a/apps/example-web/src/App.css +++ b/apps/example-web/src/App.css @@ -29,6 +29,10 @@ body { align-items: center; } +.enriched-text-container { + width: 100%; +} + .app-title { font-size: 24px; font-weight: bold; diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 8ad87c25..7cf449f5 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -14,9 +14,10 @@ import { type OnSubmitEditing, type OnChangeMentionEvent, type OnMentionDetected, + EnrichedText, } from 'react-native-enriched-html'; import { WEB_DEFAULT_HTML_STYLE } from './defaultHtmlStyle'; -import type { NativeSyntheticEvent } from 'react-native'; +import type { NativeSyntheticEvent, TextStyle } from 'react-native'; import { EditorActions } from './components/EditorActions'; import { SetValueModal } from './components/SetValueModal'; import { ImageModal } from './components/ImageModal'; @@ -53,6 +54,8 @@ function App() { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); const [isImageModalOpen, setIsImageModalOpen] = useState(false); + const [enrichedTextValue, setEnrichedTextValue] = useState(''); + const isLinkActive = !!editorState?.link.isActive; const hasLinkUrl = currentLink.url.length > 0; const hasLinkSpan = currentLink.start !== 0 || currentLink.end !== 0; @@ -226,6 +229,18 @@ function App() { } }; + const handleSetEnrichedTextValue = () => { + ref.current + ?.getHTML() + .then((html) => { + setEnrichedTextValue(html); + }) + .catch((error: unknown) => { + setEnrichedTextValue(''); + console.error('Failed to get HTML:', error); + }); + }; + return (

Enriched Text Input

@@ -303,8 +318,26 @@ function App() { }} /> + + {showHtmlOutput && } +
+

Enriched Text

+ + {enrichedTextValue} + +
+ {isSetValueModalOpen && ( { @@ -345,4 +378,14 @@ const enrichedInputStyle: EnrichedInputStyle = { fontSize: 18, }; +const enrichedTextStyle: TextStyle = { + backgroundColor: 'gainsboro', + width: '100%', + marginVertical: 12, + paddingVertical: 12, + paddingHorizontal: 14, + borderRadius: 8, + fontSize: 18, +}; + export default App; diff --git a/apps/example-web/src/EnrichedTextApp.tsx b/apps/example-web/src/EnrichedTextApp.tsx deleted file mode 100644 index afd9fec8..00000000 --- a/apps/example-web/src/EnrichedTextApp.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { EnrichedText } from 'react-native-enriched-html'; -import './App.css'; -import type { TextStyle } from 'react-native'; - -const SAMPLE_HTML = - '

Enriched Text

' + - '

This is bold italic both

' + - '

Click a link

' + - '
  • First item
  • Second item
  • Third item
'; - -function EnrichedTextApp() { - return ( -
-

Enriched Text

- {SAMPLE_HTML} -
- ); -} - -const enrichedTextStyle: TextStyle = { - backgroundColor: 'gainsboro', - width: '100%', - marginVertical: 12, - maxHeight: 300, - paddingVertical: 12, - paddingHorizontal: 14, - borderRadius: 8, - fontSize: 18, -}; - -export default EnrichedTextApp; diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index 98e98739..d04f3d2c 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -5,7 +5,6 @@ import { TestSetSelection } from './testScreens/TestSetSelection'; import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; import { useEffect, useState } from 'react'; -import EnrichedTextApp from './EnrichedTextApp'; export default function RouteSelector() { const [path, setPath] = useState(window.location.pathname); @@ -41,9 +40,5 @@ export default function RouteSelector() { return ; } - if (path === '/text') { - return ; - } - return ; } diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 67166bf5..3c185715 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -1,3 +1,253 @@ -.et ::selection { +.tiptap.ProseMirror p { + margin-top: 0; + margin-bottom: 0; +} + +.tiptap.ProseMirror-focused { + outline: none; +} + +.et-view p { + margin-top: 0; + margin-bottom: 0; +} + +.eti-editor p.is-editor-empty:first-child::before { + color: var(--et-placeholder-text-color, #adb5bd); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.eti-editor ::selection, +.et-view ::selection { background-color: var(--et-selection-color, Highlight); } + +.eti-editor code, +.et-view code { + font-family: monospace; + background-color: var(--et-code-bg-color); + color: var(--et-code-color); + display: inline; + padding: 0; + line-height: inherit; + vertical-align: baseline; +} + +.eti-editor h1, +.et-view h1 { + font-size: var(--et-h1-font-size); + font-weight: var(--et-h1-font-weight); + margin: 0; + line-height: normal; +} +.eti-editor h2, +.et-view h2 { + font-size: var(--et-h2-font-size); + font-weight: var(--et-h2-font-weight); + margin: 0; + line-height: normal; +} +.eti-editor h3, +.et-view h3 { + font-size: var(--et-h3-font-size); + font-weight: var(--et-h3-font-weight); + margin: 0; + line-height: normal; +} +.eti-editor h4, +.et-view h4 { + font-size: var(--et-h4-font-size); + font-weight: var(--et-h4-font-weight); + margin: 0; + line-height: normal; +} +.eti-editor h5, +.et-view h5 { + font-size: var(--et-h5-font-size); + font-weight: var(--et-h5-font-weight); + margin: 0; + line-height: normal; +} +.eti-editor h6, +.et-view h6 { + font-size: var(--et-h6-font-size); + font-weight: var(--et-h6-font-weight); + margin: 0; + line-height: normal; +} + +.eti-editor blockquote, +.et-view blockquote { + border-left: var(--et-blockquote-border-width) solid + var(--et-blockquote-border-color); + padding-left: var(--et-blockquote-gap-width); + margin: 0; + color: var(--et-blockquote-color); +} + +.eti-editor blockquote p, +.et-view blockquote p { + margin: 0; +} + +.eti-editor codeblock, +.et-view codeblock { + display: block; + font-family: monospace; + background-color: var(--et-codeblock-bg-color); + color: var(--et-codeblock-color); + border-radius: var(--et-codeblock-border-radius); +} + +.eti-editor codeblock p, +.et-view codeblock p { + margin: 0; +} + +.eti-editor a, +.et-view a { + color: var(--et-link-color); + text-decoration-line: var(--et-link-text-decoration-line); +} + +.eti-editor ul:not([data-type]), +.et-view ul:not([data-type]) { + margin: 0; + list-style: none; + padding-left: calc( + var(--et-ul-margin-left, 16px) + var(--et-ul-gap-width, 16px) + ); +} + +.eti-editor ul:not([data-type]) > li, +.et-view ul:not([data-type]) > li { + position: relative; +} + +.eti-editor ul:not([data-type]) > li::before, +.et-view ul:not([data-type]) > li::before { + content: ''; + position: absolute; + left: calc(-1 * var(--et-ul-gap-width, 16px) - var(--et-ul-bullet-size, 8px)); + bottom: 0.4em; + @supports (bottom: 0.5lh) { + bottom: 0.5lh; + } + + transform: translateY(50%); + width: var(--et-ul-bullet-size, 8px); + height: var(--et-ul-bullet-size, 8px); + border-radius: 50%; + background-color: var(--et-ul-bullet-color, currentColor); + pointer-events: none; +} + +.eti-editor ol, +.et-view ol { + margin: 0; + list-style: none; + padding-left: calc( + var(--et-ol-margin-left, 16px) + var(--et-ol-gap-width, 16px) + ); + counter-reset: eti-ol; +} + +.eti-editor ol > li, +.et-view ol > li { + position: relative; + counter-increment: eti-ol; +} + +.eti-editor ol > li::before, +.et-view ol > li::before { + content: counter(eti-ol) '.'; + position: absolute; + left: calc(-1 * var(--et-ol-gap-width, 16px)); + bottom: 0; + transform: translateX(-50%); + color: var(--et-ol-marker-color, currentColor); + font-weight: var(--et-ol-marker-font-weight, inherit); + white-space: nowrap; + pointer-events: none; +} + +.eti-editor li, +.et-view li { + margin: 0; +} + +.eti-editor li p, +.et-view li p { + margin: 0; +} + +.eti-editor .eti-inline-image { + vertical-align: text-bottom; + line-height: 0; + display: inline-block; +} + +.eti-editor .eti-inline-image-img { + object-fit: contain; +} + +.eti-editor .eti-inline-image--placeholder { + display: inline-block; + overflow: hidden; + color: inherit; +} + +.eti-editor .eti-inline-image-broken-glyph { + display: block; + width: 100%; + height: 100%; +} + +.eti-editor + .react-renderer.node-image.ProseMirror-selectednode + .eti-inline-image { + box-shadow: inset 0 0 0 999px Highlight; +} + +.eti-editor ul[data-type='checkboxList'] { + margin: 0; + list-style: none; + padding: 0; + padding-left: calc( + var(--et-checkbox-margin-left, 16px) + var(--et-checkbox-gap-width, 16px) + ); +} + +.eti-editor ul[data-type='checkboxList'] > li { + position: relative; + list-style: none; +} + +.eti-editor ul[data-type='checkboxList'] > li > label { + position: absolute; + left: calc( + -1 * var(--et-checkbox-gap-width, 16px) - var(--et-checkbox-box-size, 24px) + ); + transform: translateY(50%); + display: inline-flex; + align-items: center; + gap: 0; + margin: 0; + padding: 0; + user-select: none; + bottom: 0.4em; + @supports (bottom: 0.5lh) { + bottom: 0.5lh; + } +} + +.eti-editor ul[data-type='checkboxList'] > li > label input[type='checkbox'] { + width: var(--et-checkbox-box-size, 24px); + height: var(--et-checkbox-box-size, 24px); + margin: 0; + flex-shrink: 0; + accent-color: var(--et-checkbox-box-color, #0000ff); +} diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index a5edec2d..e7bab1b4 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -2,10 +2,14 @@ import { useMemo, type CSSProperties } from 'react'; import type { EnrichedTextProps } from '../types'; import './EnrichedText.css'; import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties'; -import { enrichedTextThemingToCSSProperties } from './styleConversion/enrichedTextThemingToCSSProperties'; +import { htmlStyleToCSSVariables } from './styleConversion/htmlStyleToCSSVariables'; +import { ENRICHED_TEXT_CLASSNAME } from './consts/classNames'; +import { enrichedInputThemingToCSSProperties } from './styleConversion/enrichedInputThemingToCSSProperties'; +import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS'; export const EnrichedText = ({ children, + htmlStyle, style, selectionColor, }: EnrichedTextProps) => { @@ -14,21 +18,32 @@ export const EnrichedText = ({ [style] ); + const cssVars = useMemo( + () => htmlStyleToCSSVariables(htmlStyle), + [htmlStyle] + ); + const themingStyle = useMemo( - () => enrichedTextThemingToCSSProperties({ selectionColor }), + () => enrichedInputThemingToCSSProperties({ selectionColor }), [selectionColor] ); + const mentionRulesCSS = useMemo( + () => buildMentionRulesCSS('text', htmlStyle), + [htmlStyle] + ); + const finalStyle = useMemo( - () => ({ ...textStyle, ...themingStyle }), - [textStyle, themingStyle] + () => ({ ...textStyle, ...themingStyle, ...cssVars }), + [textStyle, themingStyle, cssVars] ); return ( <> +
diff --git a/src/web/EnrichedTextInput.css b/src/web/EnrichedTextInput.css deleted file mode 100644 index c4a41ef5..00000000 --- a/src/web/EnrichedTextInput.css +++ /dev/null @@ -1,201 +0,0 @@ -.tiptap.ProseMirror p { - margin-top: 0; - margin-bottom: 0; -} - -.tiptap.ProseMirror-focused { - outline: none; -} - -.eti-editor p.is-editor-empty:first-child::before { - color: var(--eti-placeholder-text-color, #adb5bd); - content: attr(data-placeholder); - float: left; - height: 0; - pointer-events: none; -} - -.eti-editor ::selection { - background-color: var(--eti-selection-color, Highlight); -} - -.eti-editor code { - font-family: monospace; - background-color: var(--eti-code-bg-color); - color: var(--eti-code-color); - display: inline; - padding: 0; - line-height: inherit; - vertical-align: baseline; -} - -.eti-editor h1 { font-size: var(--eti-h1-font-size); font-weight: var(--eti-h1-font-weight); margin: 0; line-height: normal; } -.eti-editor h2 { font-size: var(--eti-h2-font-size); font-weight: var(--eti-h2-font-weight); margin: 0; line-height: normal; } -.eti-editor h3 { font-size: var(--eti-h3-font-size); font-weight: var(--eti-h3-font-weight); margin: 0; line-height: normal; } -.eti-editor h4 { font-size: var(--eti-h4-font-size); font-weight: var(--eti-h4-font-weight); margin: 0; line-height: normal; } -.eti-editor h5 { font-size: var(--eti-h5-font-size); font-weight: var(--eti-h5-font-weight); margin: 0; line-height: normal; } -.eti-editor h6 { font-size: var(--eti-h6-font-size); font-weight: var(--eti-h6-font-weight); margin: 0; line-height: normal; } - -.eti-editor blockquote { - border-left: var(--eti-blockquote-border-width) solid var(--eti-blockquote-border-color); - padding-left: var(--eti-blockquote-gap-width); - margin: 0; - color: var(--eti-blockquote-color); -} - -.eti-editor blockquote p { - margin: 0; -} - -.eti-editor codeblock { - display: block; - font-family: monospace; - background-color: var(--eti-codeblock-bg-color); - color: var(--eti-codeblock-color); - border-radius: var(--eti-codeblock-border-radius); -} - -.eti-editor codeblock p { - margin: 0; -} - -.eti-editor a { - color: var(--eti-link-color); - text-decoration-line: var(--eti-link-text-decoration-line); -} - -.eti-editor ul:not([data-type]) { - margin: 0; - list-style: none; - padding-left: calc( - var(--eti-ul-margin-left, 16px) + var(--eti-ul-gap-width, 16px) - ); -} - -.eti-editor ul:not([data-type]) > li { - position: relative; -} - -.eti-editor ul:not([data-type]) > li::before { - content: ''; - position: absolute; - left: calc( - -1 * var(--eti-ul-gap-width, 16px) - var(--eti-ul-bullet-size, 8px) - ); - bottom: 0.4em; - @supports (bottom: 0.5lh) { - bottom: 0.5lh; - } - - transform: translateY(50%); - width: var(--eti-ul-bullet-size, 8px); - height: var(--eti-ul-bullet-size, 8px); - border-radius: 50%; - background-color: var(--eti-ul-bullet-color, currentColor); - pointer-events: none; -} - -.eti-editor ol { - margin: 0; - list-style: none; - padding-left: calc( - var(--eti-ol-margin-left, 16px) + var(--eti-ol-gap-width, 16px) - ); - counter-reset: eti-ol; -} - -.eti-editor ol > li { - position: relative; - counter-increment: eti-ol; -} - -.eti-editor ol > li::before { - content: counter(eti-ol) '.'; - position: absolute; - left: calc(-1 * var(--eti-ol-gap-width, 16px)); - bottom: 0; - transform: translateX(-50%); - color: var(--eti-ol-marker-color, currentColor); - font-weight: var(--eti-ol-marker-font-weight, inherit); - white-space: nowrap; - pointer-events: none; -} - -.eti-editor li { - margin: 0; -} - -.eti-editor li p { - margin: 0; -} - -.eti-editor .eti-inline-image { - vertical-align: text-bottom; - line-height: 0; - display: inline-block; -} - -.eti-editor .eti-inline-image-img { - object-fit: contain; -} - -.eti-editor .eti-inline-image--placeholder { - display: inline-block; - overflow: hidden; - color: inherit; -} - -.eti-editor .eti-inline-image-broken-glyph { - display: block; - width: 100%; - height: 100%; -} - - -.eti-editor - .react-renderer.node-image.ProseMirror-selectednode - .eti-inline-image { - box-shadow: inset 0 0 0 999px Highlight; -} - - - -.eti-editor ul[data-type="checkboxList"] { - margin: 0; - list-style: none; - padding: 0; - padding-left: calc( - var(--eti-checkbox-margin-left, 16px) + var(--eti-checkbox-gap-width, 16px) - ); -} - -.eti-editor ul[data-type="checkboxList"] > li { - position: relative; - list-style: none; -} - -.eti-editor ul[data-type="checkboxList"] > li > label { - position: absolute; - left: calc( - -1 * var(--eti-checkbox-gap-width, 16px) - var(--eti-checkbox-box-size, 24px) - ); - transform: translateY(50%); - display: inline-flex; - align-items: center; - gap: 0; - margin: 0; - padding: 0; - user-select: none; - bottom: 0.4em; - @supports (bottom: 0.5lh) { - bottom: 0.5lh; - } -} - -.eti-editor ul[data-type="checkboxList"] > li > label input[type='checkbox'] { - width: var(--eti-checkbox-box-size, 24px); - height: var(--eti-checkbox-box-size, 24px); - margin: 0; - flex-shrink: 0; - accent-color: var(--eti-checkbox-box-color, #0000ff); -} diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 06a0c264..c5fa4bbe 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -5,7 +5,7 @@ import { useRef, type CSSProperties, } from 'react'; -import './EnrichedTextInput.css'; +import './EnrichedText.css'; import type { Node } from '@tiptap/pm/model'; import type { EnrichedTextInputInstance, @@ -74,6 +74,7 @@ import { import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin'; import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin'; import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint'; +import { ENRICHED_TEXT_INPUT_CLASSNAME } from './consts/classNames'; function runFocused( editor: Editor, apply: (chain: ChainedCommands) => ChainedCommands @@ -393,7 +394,7 @@ export const EnrichedTextInput = ({ ); const mentionRulesCSS = useMemo( - () => buildMentionRulesCSS(resolvedHtmlStyle), + () => buildMentionRulesCSS('input', resolvedHtmlStyle), [resolvedHtmlStyle] ); @@ -407,7 +408,7 @@ export const EnrichedTextInput = ({ {mentionRulesCSS ? : null} diff --git a/src/web/consts/classNames.ts b/src/web/consts/classNames.ts new file mode 100644 index 00000000..7d45c377 --- /dev/null +++ b/src/web/consts/classNames.ts @@ -0,0 +1,2 @@ +export const ENRICHED_TEXT_INPUT_CLASSNAME = 'eti-editor'; +export const ENRICHED_TEXT_CLASSNAME = 'et-view'; diff --git a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts index 346bd9ce..1f0808e1 100644 --- a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts +++ b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts @@ -1,30 +1,50 @@ import { mergeWithDefaultHtmlStyle } from '../htmlStyleToCSSVariables'; import { buildMentionRulesCSS } from '../buildMentionRulesCSS'; +import { + ENRICHED_TEXT_CLASSNAME, + ENRICHED_TEXT_INPUT_CLASSNAME, +} from '../../consts/classNames'; describe('buildMentionRulesCSS', () => { - it('emits default class rule and attribute rule for @', () => { - const merged = mergeWithDefaultHtmlStyle({ - mention: { '@': { color: 'red' } }, - }); - const css = buildMentionRulesCSS(merged); + it.each([ + { + description: 'EnrichedTextInput', + input: 'input' as const, + expected: ENRICHED_TEXT_INPUT_CLASSNAME, + }, + { + description: 'EnrichedText', + input: 'text' as const, + expected: ENRICHED_TEXT_CLASSNAME, + }, + ])( + '[$description] emits default class rule and attribute rule for @', + ({ input, expected }) => { + const merged = mergeWithDefaultHtmlStyle({ + mention: { '@': { color: 'red' } }, + }); + const css = buildMentionRulesCSS(input, merged); - expect(css).toMatch(/\.eti-editor mention\s*\{/); - expect(css).toContain('var(--eti-mention-default-color)'); - expect(css).toContain('var(--eti-mention-default-background-color)'); - expect(css).toContain('var(--eti-mention-default-text-decoration-line)'); + expect(css).toMatch(new RegExp(`\\.${expected} mention\\s*\\{`)); + expect(css).toContain('var(--et-mention-default-color)'); + expect(css).toContain('var(--et-mention-default-background-color)'); + expect(css).toContain('var(--et-mention-default-text-decoration-line)'); - expect(css).toContain('.eti-editor mention[indicator="@"]'); - expect(css).toContain('var(--eti-mention-u0040-color)'); - expect(css).toContain('var(--eti-mention-u0040-background-color)'); - expect(css).toContain('var(--eti-mention-u0040-text-decoration-line)'); - }); + expect(css).toContain(`.${expected} 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)'); + } + ); it('returns empty string when mention is missing', () => { - expect(buildMentionRulesCSS(undefined)).toBe(''); - expect(buildMentionRulesCSS({})).toBe(''); + expect(buildMentionRulesCSS('input', undefined)).toBe(''); + expect(buildMentionRulesCSS('input', {})).toBe(''); }); it('returns empty string when mention is not a style record', () => { - expect(buildMentionRulesCSS({ mention: { color: 'red' } })).toBe(''); + expect(buildMentionRulesCSS('input', { mention: { color: 'red' } })).toBe( + '' + ); }); }); diff --git a/src/web/styleConversion/__tests__/enrichedInputThemingToCSSProperties.test.ts b/src/web/styleConversion/__tests__/enrichedInputThemingToCSSProperties.test.ts index 44221eff..9ea966df 100644 --- a/src/web/styleConversion/__tests__/enrichedInputThemingToCSSProperties.test.ts +++ b/src/web/styleConversion/__tests__/enrichedInputThemingToCSSProperties.test.ts @@ -15,8 +15,8 @@ describe('enrichedInputThemingToCSSProperties', () => { }) ).toEqual({ 'caretColor': '#111', - '--eti-placeholder-text-color': '#222', - '--eti-selection-color': '#333', + '--et-placeholder-text-color': '#222', + '--et-selection-color': '#333', } as CSSProperties); }); }); diff --git a/src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts b/src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts index adf1b60e..513cba03 100644 --- a/src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts +++ b/src/web/styleConversion/__tests__/enrichedTextStyleToCSSProperties.test.ts @@ -18,7 +18,9 @@ function convert(style: TextStyle): CSSProperties { describe('empty input', () => { it('returns an empty object for an empty style', () => { - expect(convert({})).toEqual({}); + expect(convert({})).toEqual({ + overflowY: 'hidden', + }); }); }); @@ -27,17 +29,26 @@ describe('textAlign', () => { { description: 'left passes through', input: { textAlign: 'left' }, - expected: { textAlign: 'left' }, + expected: { + overflowY: 'hidden', + textAlign: 'left', + }, }, { description: 'center passes through', input: { textAlign: 'center' }, - expected: { textAlign: 'center' }, + expected: { + overflowY: 'hidden', + textAlign: 'center', + }, }, { description: 'justify passes through', input: { textAlign: 'justify' }, - expected: { textAlign: 'justify' }, + expected: { + overflowY: 'hidden', + textAlign: 'justify', + }, }, ]; @@ -55,6 +66,7 @@ describe('textTransform', () => { '%s passes through', (value) => { expect(convert({ textTransform: value })).toEqual({ + overflowY: 'hidden', textTransform: value, }); } @@ -66,27 +78,42 @@ describe('text decoration', () => { { description: 'textDecorationLine underline passes through', input: { textDecorationLine: 'underline' }, - expected: { textDecorationLine: 'underline' }, + expected: { + overflowY: 'hidden', + textDecorationLine: 'underline', + }, }, { description: 'textDecorationLine combined value passes through', input: { textDecorationLine: 'underline line-through' }, - expected: { textDecorationLine: 'underline line-through' }, + expected: { + overflowY: 'hidden', + textDecorationLine: 'underline line-through', + }, }, { description: 'textDecorationStyle passes through', input: { textDecorationStyle: 'dashed' }, - expected: { textDecorationStyle: 'dashed' }, + expected: { + overflowY: 'hidden', + textDecorationStyle: 'dashed', + }, }, { description: 'textDecorationColor (string) passes through', input: { textDecorationColor: 'red' }, - expected: { textDecorationColor: 'red' }, + expected: { + overflowY: 'hidden', + textDecorationColor: 'red', + }, }, { description: 'textDecorationColor (integer) converts to rgba', input: { textDecorationColor: 0xff0000ff } as unknown as TextStyle, - expected: { textDecorationColor: 'rgba(255, 0, 0, 1)' }, + expected: { + overflowY: 'hidden', + textDecorationColor: 'rgba(255, 0, 0, 1)', + }, }, ]; @@ -103,11 +130,15 @@ describe('textShadow', () => { textShadowOffset: { width: 1, height: 2 }, textShadowRadius: 3, }) - ).toEqual({ textShadow: '1px 2px 3px black' }); + ).toEqual({ + overflowY: 'hidden', + textShadow: '1px 2px 3px black', + }); }); it('defaults missing offset and radius to 0', () => { expect(convert({ textShadowColor: 'red' })).toEqual({ + overflowY: 'hidden', textShadow: '0px 0px 0px red', }); }); @@ -118,7 +149,10 @@ describe('textShadow', () => { textShadowOffset: { width: 4, height: 5 }, textShadowRadius: 6, }) - ).toEqual({ textShadow: '4px 5px 6px' }); + ).toEqual({ + overflowY: 'hidden', + textShadow: '4px 5px 6px', + }); }); it('converts integer color values', () => { @@ -127,7 +161,10 @@ describe('textShadow', () => { textShadowColor: 0x00000080, textShadowOffset: { width: 0, height: 1 }, } as unknown as TextStyle) - ).toEqual({ textShadow: `0px 1px 0px rgba(0, 0, 0, ${128 / 255})` }); + ).toEqual({ + overflowY: 'hidden', + textShadow: `0px 1px 0px rgba(0, 0, 0, ${128 / 255})`, + }); }); it('is omitted when no textShadow props are set', () => { @@ -139,7 +176,10 @@ describe('userSelect', () => { it.each(['auto', 'none', 'text', 'contain', 'all'] as const)( '%s passes through', (value) => { - expect(convert({ userSelect: value })).toEqual({ userSelect: value }); + expect(convert({ userSelect: value })).toEqual({ + overflowY: 'hidden', + userSelect: value, + }); } ); }); @@ -147,12 +187,14 @@ describe('userSelect', () => { describe('fontVariant', () => { it('joins the RN array into a CSS string', () => { expect(convert({ fontVariant: ['small-caps', 'tabular-nums'] })).toEqual({ + overflowY: 'hidden', fontVariant: 'small-caps tabular-nums', }); }); it('joins a single-item array', () => { expect(convert({ fontVariant: ['small-caps'] })).toEqual({ + overflowY: 'hidden', fontVariant: 'small-caps', }); }); @@ -160,7 +202,10 @@ describe('fontVariant', () => { describe('writingDirection → direction', () => { it.each(['ltr', 'rtl'] as const)('%s maps to direction', (value) => { - expect(convert({ writingDirection: value })).toEqual({ direction: value }); + expect(convert({ writingDirection: value })).toEqual({ + overflowY: 'hidden', + direction: value, + }); }); it("omits 'auto' (no CSS equivalent)", () => { @@ -175,6 +220,7 @@ describe('verticalAlign', () => { '%s passes through', (value) => { expect(convert({ verticalAlign: value })).toEqual({ + overflowY: 'hidden', verticalAlign: value, }); } @@ -198,6 +244,7 @@ describe('combined text style', () => { userSelect: 'none', }) ).toEqual({ + overflowY: 'hidden', textAlign: 'center', textTransform: 'uppercase', textDecorationLine: 'underline', diff --git a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts index 6865329a..f7d9bcf5 100644 --- a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts +++ b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts @@ -9,11 +9,11 @@ import { type CodeStyle = HtmlStyle['code']; const DEFAULT_MENTION_CSS_VARS: Record = { - '--eti-mention-default-color': String(DEFAULT_HTML_STYLE.mention.color), - '--eti-mention-default-background-color': String( + '--et-mention-default-color': String(DEFAULT_HTML_STYLE.mention.color), + '--et-mention-default-background-color': String( DEFAULT_HTML_STYLE.mention.backgroundColor ), - '--eti-mention-default-text-decoration-line': String( + '--et-mention-default-text-decoration-line': String( DEFAULT_HTML_STYLE.mention.textDecorationLine ), }; @@ -123,21 +123,21 @@ describe('htmlStyleToCSSVariables', () => { it('integer color → rgba string', () => { const input = { code: { color: 0xff0000ff as unknown as string } }; expect(htmlStyleToCSSVariables(input)).toMatchObject({ - '--eti-code-color': 'rgba(255, 0, 0, 1)', + '--et-code-color': 'rgba(255, 0, 0, 1)', }); }); describe('code styles', () => { const cases = [ - [{ color: '#ff0000' }, { '--eti-code-color': '#ff0000' }], + [{ color: '#ff0000' }, { '--et-code-color': '#ff0000' }], [ { color: 'rgba(0,128,255,1)' }, - { '--eti-code-color': 'rgba(0,128,255,1)' }, + { '--et-code-color': 'rgba(0,128,255,1)' }, ], - [{ backgroundColor: '#f5f5f5' }, { '--eti-code-bg-color': '#f5f5f5' }], + [{ backgroundColor: '#f5f5f5' }, { '--et-code-bg-color': '#f5f5f5' }], [ { color: '#333', backgroundColor: '#f5f5f5' }, - { '--eti-code-color': '#333', '--eti-code-bg-color': '#f5f5f5' }, + { '--et-code-color': '#333', '--et-code-bg-color': '#f5f5f5' }, ], [{}, {}], [undefined, {}], @@ -153,18 +153,18 @@ describe('htmlStyleToCSSVariables', () => { [ { h1: { fontSize: 24, bold: true } }, { - '--eti-h1-font-size': '24px', - '--eti-h1-font-weight': 'bold', + '--et-h1-font-size': '24px', + '--et-h1-font-weight': 'bold', }, ], [ { h2: { fontSize: 20, bold: false } }, { - '--eti-h2-font-size': '20px', - '--eti-h2-font-weight': 'normal', + '--et-h2-font-size': '20px', + '--et-h2-font-weight': 'normal', }, ], - [{ h3: { fontSize: 18 } }, { '--eti-h3-font-size': '18px' }], + [{ h3: { fontSize: 18 } }, { '--et-h3-font-size': '18px' }], [{}, {}], ] as Array<[HtmlStyle, CSSProperties]>; @@ -184,10 +184,10 @@ describe('htmlStyleToCSSVariables', () => { }, }) ).toMatchObject({ - '--eti-blockquote-border-color': '#ccc', - '--eti-blockquote-border-width': '3px', - '--eti-blockquote-gap-width': '12px', - '--eti-blockquote-color': '#444', + '--et-blockquote-border-color': '#ccc', + '--et-blockquote-border-width': '3px', + '--et-blockquote-gap-width': '12px', + '--et-blockquote-color': '#444', }); }); @@ -201,9 +201,9 @@ describe('htmlStyleToCSSVariables', () => { }, }) ).toMatchObject({ - '--eti-codeblock-bg-color': '#1e1e1e', - '--eti-codeblock-color': '#d4d4d4', - '--eti-codeblock-border-radius': '8px', + '--et-codeblock-bg-color': '#1e1e1e', + '--et-codeblock-color': '#d4d4d4', + '--et-codeblock-border-radius': '8px', }); }); @@ -213,8 +213,8 @@ describe('htmlStyleToCSSVariables', () => { a: { color: 'blue', textDecorationLine: 'underline' }, }) ).toMatchObject({ - '--eti-link-color': 'blue', - '--eti-link-text-decoration-line': 'underline', + '--et-link-color': 'blue', + '--et-link-text-decoration-line': 'underline', }); }); @@ -229,10 +229,10 @@ describe('htmlStyleToCSSVariables', () => { }, }) ).toMatchObject({ - '--eti-ul-bullet-color': '#ff0000', - '--eti-ul-bullet-size': '12px', - '--eti-ul-margin-left': '8px', - '--eti-ul-gap-width': '4px', + '--et-ul-bullet-color': '#ff0000', + '--et-ul-bullet-size': '12px', + '--et-ul-margin-left': '8px', + '--et-ul-gap-width': '4px', }); }); @@ -247,10 +247,10 @@ describe('htmlStyleToCSSVariables', () => { }, }) ).toMatchObject({ - '--eti-ol-gap-width': '6px', - '--eti-ol-margin-left': '10px', - '--eti-ol-marker-font-weight': '700', - '--eti-ol-marker-color': '#00ff00', + '--et-ol-gap-width': '6px', + '--et-ol-margin-left': '10px', + '--et-ol-marker-font-weight': '700', + '--et-ol-marker-color': '#00ff00', }); }); @@ -265,10 +265,10 @@ describe('htmlStyleToCSSVariables', () => { }, }) ).toMatchObject({ - '--eti-checkbox-box-size': '20px', - '--eti-checkbox-gap-width': '8px', - '--eti-checkbox-margin-left': '12px', - '--eti-checkbox-box-color': '#336699', + '--et-checkbox-box-size': '20px', + '--et-checkbox-gap-width': '8px', + '--et-checkbox-margin-left': '12px', + '--et-checkbox-box-color': '#336699', }); }); }); @@ -278,7 +278,7 @@ describe('mention CSS variables', () => { const vars = htmlStyleToCSSVariables({ mention: { color: '#f00' }, }) as Record; - expect(vars['--eti-mention-default-color']).toBe('#f00'); + expect(vars['--et-mention-default-color']).toBe('#f00'); }); it('mention @ + default vars', () => { @@ -286,8 +286,8 @@ describe('mention CSS variables', () => { mention: { '@': { color: '#ff0000' } }, }); const vars = htmlStyleToCSSVariables(merged) as Record; - expect(vars['--eti-mention-u0040-color']).toBe('#ff0000'); - expect(vars['--eti-mention-default-color']).toBe( + expect(vars['--et-mention-u0040-color']).toBe('#ff0000'); + expect(vars['--et-mention-default-color']).toBe( DEFAULT_HTML_STYLE.mention.color ); }); @@ -297,7 +297,7 @@ describe('mention CSS variables', () => { string, string >; - expect(vars['--eti-mention-default-color']).toBe( + expect(vars['--et-mention-default-color']).toBe( DEFAULT_HTML_STYLE.mention.color ); }); diff --git a/src/web/styleConversion/buildMentionRulesCSS.ts b/src/web/styleConversion/buildMentionRulesCSS.ts index eb22197a..2fa3c16e 100644 --- a/src/web/styleConversion/buildMentionRulesCSS.ts +++ b/src/web/styleConversion/buildMentionRulesCSS.ts @@ -1,6 +1,10 @@ import type { HtmlStyle, MentionStyleProperties } from '../../types'; import { isMentionStyleRecord } from '../../utils/isMentionStyleRecord'; -import { ETI_MENTION_CSS_VARS } from './htmlStyleToCSSVariables'; +import { + ENRICHED_TEXT_CLASSNAME, + ENRICHED_TEXT_INPUT_CLASSNAME, +} from '../consts/classNames'; +import { ET_MENTION_CSS_VARS } from './htmlStyleToCSSVariables'; import { MENTION_STYLE_DEFAULT_KEY } from './mentionIndicatorCssKey'; function escapeIndicatorForCssAttributeSelector(indicator: string): string { @@ -10,7 +14,10 @@ function escapeIndicatorForCssAttributeSelector(indicator: string): string { return indicator.replace(/["\\]/g, '\\$&'); } -export function buildMentionRulesCSS(htmlStyle?: HtmlStyle): string { +export function buildMentionRulesCSS( + component: 'input' | 'text', + htmlStyle?: HtmlStyle +): string { const mapRaw = htmlStyle?.mention; if (!mapRaw || typeof mapRaw !== 'object' || !isMentionStyleRecord(mapRaw)) { return ''; @@ -22,18 +29,23 @@ export function buildMentionRulesCSS(htmlStyle?: HtmlStyle): string { return ''; } + const className = + component === 'input' + ? ENRICHED_TEXT_INPUT_CLASSNAME + : ENRICHED_TEXT_CLASSNAME; + const lines: string[] = []; for (const indicator of keys) { const selector = indicator === MENTION_STYLE_DEFAULT_KEY - ? '.eti-editor mention' - : `.eti-editor mention[indicator="${escapeIndicatorForCssAttributeSelector(indicator)}"]`; + ? `.${className} mention` + : `.${className} mention[indicator="${escapeIndicatorForCssAttributeSelector(indicator)}"]`; lines.push( `${selector} { - color: var(${ETI_MENTION_CSS_VARS.color(indicator)}); - background-color: var(${ETI_MENTION_CSS_VARS.backgroundColor(indicator)}); - text-decoration-line: var(${ETI_MENTION_CSS_VARS.textDecorationLine(indicator)}); + 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)}); }`.trim() ); } diff --git a/src/web/styleConversion/enrichedInputThemingToCSSProperties.ts b/src/web/styleConversion/enrichedInputThemingToCSSProperties.ts index 7ed8a32f..d4ca7bb7 100644 --- a/src/web/styleConversion/enrichedInputThemingToCSSProperties.ts +++ b/src/web/styleConversion/enrichedInputThemingToCSSProperties.ts @@ -4,8 +4,8 @@ import { toColor } from './toColor'; type EnrichedInputThemingStyle = Partial< Pick & { - '--eti-placeholder-text-color': string; - '--eti-selection-color': string; + '--et-placeholder-text-color': string; + '--et-selection-color': string; } >; @@ -25,10 +25,10 @@ export function enrichedInputThemingToCSSProperties({ if (caret) extra.caretColor = caret; const placeholderCss = toColor(placeholderTextColor); - if (placeholderCss) extra['--eti-placeholder-text-color'] = placeholderCss; + if (placeholderCss) extra['--et-placeholder-text-color'] = placeholderCss; const selectionCss = toColor(selectionColor); - if (selectionCss) extra['--eti-selection-color'] = selectionCss; + if (selectionCss) extra['--et-selection-color'] = selectionCss; return extra; } diff --git a/src/web/styleConversion/enrichedTextStyleToCSSProperties.ts b/src/web/styleConversion/enrichedTextStyleToCSSProperties.ts index 365a8496..4b1aadb7 100644 --- a/src/web/styleConversion/enrichedTextStyleToCSSProperties.ts +++ b/src/web/styleConversion/enrichedTextStyleToCSSProperties.ts @@ -1,17 +1,13 @@ import type { CSSProperties } from 'react'; import type { TextStyle } from 'react-native'; -import { - enrichedBaseStyleToCSSProperties, - type StyleConversionExtraOptions, -} from './enrichedBaseStyleToCSSProperties'; +import { enrichedBaseStyleToCSSProperties } from './enrichedBaseStyleToCSSProperties'; import { toColor } from './toColor'; export function enrichedTextStyleToCSSProperties( - style: TextStyle, - extraOptions?: StyleConversionExtraOptions + style: TextStyle ): CSSProperties { const css: CSSProperties = { - ...enrichedBaseStyleToCSSProperties(style, extraOptions), + ...enrichedBaseStyleToCSSProperties(style, { scrollEnabled: false }), // Text-only properties // textAlign: RN 'auto' has no CSS equivalent diff --git a/src/web/styleConversion/enrichedTextThemingToCSSProperties.ts b/src/web/styleConversion/enrichedTextThemingToCSSProperties.ts deleted file mode 100644 index 9df1e774..00000000 --- a/src/web/styleConversion/enrichedTextThemingToCSSProperties.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { CSSProperties } from 'react'; -import type { ColorValue } from 'react-native'; -import { toColor } from './toColor'; - -type EnrichedTextThemingStyle = Partial<{ - '--et-selection-color': string; -}>; - -export interface EnrichedTextThemingColors { - selectionColor?: ColorValue; -} - -export function enrichedTextThemingToCSSProperties({ - selectionColor, -}: EnrichedTextThemingColors): CSSProperties { - const extra: EnrichedTextThemingStyle = {}; - - const selectionCss = toColor(selectionColor); - if (selectionCss) extra['--et-selection-color'] = selectionCss; - - return extra as CSSProperties; -} diff --git a/src/web/styleConversion/htmlStyleToCSSVariables.ts b/src/web/styleConversion/htmlStyleToCSSVariables.ts index 199d1022..b78fbc9d 100644 --- a/src/web/styleConversion/htmlStyleToCSSVariables.ts +++ b/src/web/styleConversion/htmlStyleToCSSVariables.ts @@ -38,39 +38,39 @@ export function mergeWithDefaultHtmlStyle( return merged as Required; } -const ETI_CSS_VARS = { - codeColor: '--eti-code-color', - codeBgColor: '--eti-code-bg-color', - blockquoteBorderColor: '--eti-blockquote-border-color', - blockquoteBorderWidth: '--eti-blockquote-border-width', - blockquoteGapWidth: '--eti-blockquote-gap-width', - blockquoteColor: '--eti-blockquote-color', - codeblockBgColor: '--eti-codeblock-bg-color', - codeblockColor: '--eti-codeblock-color', - codeblockBorderRadius: '--eti-codeblock-border-radius', - linkColor: '--eti-link-color', - linkTextDecorationLine: '--eti-link-text-decoration-line', - ulBulletColor: '--eti-ul-bullet-color', - ulBulletSize: '--eti-ul-bullet-size', - ulMarginLeft: '--eti-ul-margin-left', - ulGapWidth: '--eti-ul-gap-width', - olMarginLeft: '--eti-ol-margin-left', - olGapWidth: '--eti-ol-gap-width', - olMarkerColor: '--eti-ol-marker-color', - olMarkerFontWeight: '--eti-ol-marker-font-weight', - checkboxBoxSize: '--eti-checkbox-box-size', - checkboxGapWidth: '--eti-checkbox-gap-width', - checkboxMarginLeft: '--eti-checkbox-margin-left', - checkboxBoxColor: '--eti-checkbox-box-color', +const ET_CSS_VARS = { + codeColor: '--et-code-color', + codeBgColor: '--et-code-bg-color', + blockquoteBorderColor: '--et-blockquote-border-color', + blockquoteBorderWidth: '--et-blockquote-border-width', + blockquoteGapWidth: '--et-blockquote-gap-width', + blockquoteColor: '--et-blockquote-color', + codeblockBgColor: '--et-codeblock-bg-color', + codeblockColor: '--et-codeblock-color', + codeblockBorderRadius: '--et-codeblock-border-radius', + linkColor: '--et-link-color', + linkTextDecorationLine: '--et-link-text-decoration-line', + ulBulletColor: '--et-ul-bullet-color', + ulBulletSize: '--et-ul-bullet-size', + ulMarginLeft: '--et-ul-margin-left', + ulGapWidth: '--et-ul-gap-width', + olMarginLeft: '--et-ol-margin-left', + olGapWidth: '--et-ol-gap-width', + olMarkerColor: '--et-ol-marker-color', + olMarkerFontWeight: '--et-ol-marker-font-weight', + checkboxBoxSize: '--et-checkbox-box-size', + checkboxGapWidth: '--et-checkbox-gap-width', + checkboxMarginLeft: '--et-checkbox-margin-left', + checkboxBoxColor: '--et-checkbox-box-color', } as const; -export const ETI_MENTION_CSS_VARS = { +export const ET_MENTION_CSS_VARS = { color: (indicator: string) => - `--eti-mention-${indicatorToMentionCssKey(indicator)}-color`, + `--et-mention-${indicatorToMentionCssKey(indicator)}-color`, backgroundColor: (indicator: string) => - `--eti-mention-${indicatorToMentionCssKey(indicator)}-background-color`, + `--et-mention-${indicatorToMentionCssKey(indicator)}-background-color`, textDecorationLine: (indicator: string) => - `--eti-mention-${indicatorToMentionCssKey(indicator)}-text-decoration-line`, + `--et-mention-${indicatorToMentionCssKey(indicator)}-text-decoration-line`, } as const; function setColorVar( @@ -94,8 +94,8 @@ function applyCodeVars( vars: Record, code?: HtmlStyle['code'] ): void { - setColorVar(vars, ETI_CSS_VARS.codeColor, code?.color); - setColorVar(vars, ETI_CSS_VARS.codeBgColor, code?.backgroundColor); + setColorVar(vars, ET_CSS_VARS.codeColor, code?.color); + setColorVar(vars, ET_CSS_VARS.codeBgColor, code?.backgroundColor); } function applyHeadingVars( @@ -105,9 +105,9 @@ function applyHeadingVars( for (const level of HEADING_TAGS) { const h = htmlStyle?.[level]; if (h?.fontSize != null) - vars[`--eti-${level}-font-size`] = `${h.fontSize}px`; + vars[`--et-${level}-font-size`] = `${h.fontSize}px`; if (h?.bold != null) - vars[`--eti-${level}-font-weight`] = h.bold ? 'bold' : 'normal'; + vars[`--et-${level}-font-weight`] = h.bold ? 'bold' : 'normal'; } } @@ -115,28 +115,28 @@ function applyBlockquoteVars( vars: Record, bq?: HtmlStyle['blockquote'] ): void { - setColorVar(vars, ETI_CSS_VARS.blockquoteBorderColor, bq?.borderColor); - setPxVar(vars, ETI_CSS_VARS.blockquoteBorderWidth, bq?.borderWidth); - setPxVar(vars, ETI_CSS_VARS.blockquoteGapWidth, bq?.gapWidth); - setColorVar(vars, ETI_CSS_VARS.blockquoteColor, bq?.color); + setColorVar(vars, ET_CSS_VARS.blockquoteBorderColor, bq?.borderColor); + setPxVar(vars, ET_CSS_VARS.blockquoteBorderWidth, bq?.borderWidth); + setPxVar(vars, ET_CSS_VARS.blockquoteGapWidth, bq?.gapWidth); + setColorVar(vars, ET_CSS_VARS.blockquoteColor, bq?.color); } function applyCodeblockVars( vars: Record, cb?: HtmlStyle['codeblock'] ): void { - setColorVar(vars, ETI_CSS_VARS.codeblockBgColor, cb?.backgroundColor); - setColorVar(vars, ETI_CSS_VARS.codeblockColor, cb?.color); - setPxVar(vars, ETI_CSS_VARS.codeblockBorderRadius, cb?.borderRadius); + setColorVar(vars, ET_CSS_VARS.codeblockBgColor, cb?.backgroundColor); + setColorVar(vars, ET_CSS_VARS.codeblockColor, cb?.color); + setPxVar(vars, ET_CSS_VARS.codeblockBorderRadius, cb?.borderRadius); } function applyLinkVars( vars: Record, anchor?: HtmlStyle['a'] ): void { - setColorVar(vars, ETI_CSS_VARS.linkColor, anchor?.color); + setColorVar(vars, ET_CSS_VARS.linkColor, anchor?.color); if (anchor?.textDecorationLine != null) { - vars[ETI_CSS_VARS.linkTextDecorationLine] = anchor.textDecorationLine; + vars[ET_CSS_VARS.linkTextDecorationLine] = anchor.textDecorationLine; } } @@ -144,21 +144,21 @@ function applyUnorderedListVars( vars: Record, ul?: HtmlStyle['ul'] ): void { - setColorVar(vars, ETI_CSS_VARS.ulBulletColor, ul?.bulletColor); - setPxVar(vars, ETI_CSS_VARS.ulBulletSize, ul?.bulletSize); - setPxVar(vars, ETI_CSS_VARS.ulMarginLeft, ul?.marginLeft); - setPxVar(vars, ETI_CSS_VARS.ulGapWidth, ul?.gapWidth); + setColorVar(vars, ET_CSS_VARS.ulBulletColor, ul?.bulletColor); + setPxVar(vars, ET_CSS_VARS.ulBulletSize, ul?.bulletSize); + setPxVar(vars, ET_CSS_VARS.ulMarginLeft, ul?.marginLeft); + setPxVar(vars, ET_CSS_VARS.ulGapWidth, ul?.gapWidth); } function applyOrderedListVars( vars: Record, ol?: HtmlStyle['ol'] ): void { - setPxVar(vars, ETI_CSS_VARS.olMarginLeft, ol?.marginLeft); - setPxVar(vars, ETI_CSS_VARS.olGapWidth, ol?.gapWidth); - setColorVar(vars, ETI_CSS_VARS.olMarkerColor, ol?.markerColor); + setPxVar(vars, ET_CSS_VARS.olMarginLeft, ol?.marginLeft); + setPxVar(vars, ET_CSS_VARS.olGapWidth, ol?.gapWidth); + setColorVar(vars, ET_CSS_VARS.olMarkerColor, ol?.markerColor); if (ol?.markerFontWeight != null) { - vars[ETI_CSS_VARS.olMarkerFontWeight] = String(ol.markerFontWeight); + vars[ET_CSS_VARS.olMarkerFontWeight] = String(ol.markerFontWeight); } } @@ -166,10 +166,10 @@ function applyCheckboxListVars( vars: Record, ulCheckbox?: HtmlStyle['ulCheckbox'] ): void { - setPxVar(vars, ETI_CSS_VARS.checkboxBoxSize, ulCheckbox?.boxSize); - setPxVar(vars, ETI_CSS_VARS.checkboxGapWidth, ulCheckbox?.gapWidth); - setPxVar(vars, ETI_CSS_VARS.checkboxMarginLeft, ulCheckbox?.marginLeft); - setColorVar(vars, ETI_CSS_VARS.checkboxBoxColor, ulCheckbox?.boxColor); + setPxVar(vars, ET_CSS_VARS.checkboxBoxSize, ulCheckbox?.boxSize); + setPxVar(vars, ET_CSS_VARS.checkboxGapWidth, ulCheckbox?.gapWidth); + setPxVar(vars, ET_CSS_VARS.checkboxMarginLeft, ulCheckbox?.marginLeft); + setColorVar(vars, ET_CSS_VARS.checkboxBoxColor, ulCheckbox?.boxColor); } function applyMentionVars( @@ -177,18 +177,14 @@ function applyMentionVars( mention: Record ): void { for (const [indicator, mentionStyle] of Object.entries(mention)) { + setColorVar(vars, ET_MENTION_CSS_VARS.color(indicator), mentionStyle.color); setColorVar( vars, - ETI_MENTION_CSS_VARS.color(indicator), - mentionStyle.color - ); - setColorVar( - vars, - ETI_MENTION_CSS_VARS.backgroundColor(indicator), + ET_MENTION_CSS_VARS.backgroundColor(indicator), mentionStyle.backgroundColor ); if (mentionStyle.textDecorationLine != null) { - vars[ETI_MENTION_CSS_VARS.textDecorationLine(indicator)] = + vars[ET_MENTION_CSS_VARS.textDecorationLine(indicator)] = mentionStyle.textDecorationLine; } } From 36f04244f563127c0a3760fa58e2a29c8a0df865 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 18 Jun 2026 17:01:41 +0200 Subject: [PATCH 04/15] feat: checkbox list conversion to web html --- apps/example-web/src/App.tsx | 1 + src/web/EnrichedText.css | 45 ++++++++++++++++++++++++++++++++++++ src/web/EnrichedText.tsx | 5 +++- src/web/checkboxHtmlToWeb.ts | 40 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/web/checkboxHtmlToWeb.ts diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 7cf449f5..ccf2d157 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -233,6 +233,7 @@ function App() { ref.current ?.getHTML() .then((html) => { + console.log('getHtml', html); setEnrichedTextValue(html); }) .catch((error: unknown) => { diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 3c185715..2bcb2aa9 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -251,3 +251,48 @@ flex-shrink: 0; accent-color: var(--et-checkbox-box-color, #0000ff); } + +.et-view ul[data-type="checkbox"] { + margin: 0; + list-style: none; + padding: 0; + padding-left: calc( + var(--et-checkbox-margin-left, 16px) + var(--et-checkbox-gap-width, 16px) + ); +} + +.et-view ul[data-type="checkbox"] > li { + position: relative; + list-style: none; + pointer-events: none; +} + +.et-view ul[data-type="checkbox"] > li > p { + display: inline-flex; + align-items: center; + gap: 0; + margin: 0; + padding: 0; + user-select: none; +} + +.et-view ul[data-type="checkbox"] > li > input[type='checkbox'] { + position: absolute; + left: calc( + -1 * var(--et-checkbox-gap-width, 16px) - var(--et-checkbox-box-size, 24px) + ); + width: var(--et-checkbox-box-size, 24px); + height: var(--et-checkbox-box-size, 24px); + margin: 0 var(--et-checkbox-gap-width, 16px) 0 0; + flex-shrink: 0; + accent-color: var(--et-checkbox-box-color, #0000ff); +} + +.et-view ul[data-type="checkbox"] > li > label { + display: inline-flex; + align-items: center; + gap: 0; + margin: 0; + padding: 0; + user-select: none; +} diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index e7bab1b4..b370356e 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -6,6 +6,7 @@ import { htmlStyleToCSSVariables } from './styleConversion/htmlStyleToCSSVariabl import { ENRICHED_TEXT_CLASSNAME } from './consts/classNames'; import { enrichedInputThemingToCSSProperties } from './styleConversion/enrichedInputThemingToCSSProperties'; import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS'; +import { checkboxHtmlToWeb } from './checkboxHtmlToWeb'; export const EnrichedText = ({ children, @@ -13,6 +14,8 @@ export const EnrichedText = ({ style, selectionColor, }: EnrichedTextProps) => { + const html = useMemo(() => checkboxHtmlToWeb(children), [children]); + const textStyle: CSSProperties = useMemo( () => enrichedTextStyleToCSSProperties(style ?? {}), [style] @@ -44,7 +47,7 @@ export const EnrichedText = ({
); diff --git a/src/web/checkboxHtmlToWeb.ts b/src/web/checkboxHtmlToWeb.ts new file mode 100644 index 00000000..e1bc8902 --- /dev/null +++ b/src/web/checkboxHtmlToWeb.ts @@ -0,0 +1,40 @@ +/* + * Native checkbox format (as produced by the editor): + *
    + *
  • foo
  • + *
  • bar
  • + *
+ * + * Web-native, display-only format: + *
    + *
  • + * + * + *
  • + *
  • + * + * + *
  • + *
+ */ +export function checkboxHtmlToWeb(html: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + let idCounter = 0; + + doc.querySelectorAll('ul[data-type="checkbox"]').forEach((ul) => { + ul.querySelectorAll('li').forEach((li) => { + const checked = li.hasAttribute('checked'); + const id = `enriched-checkbox-${idCounter++}`; + const labelContent = li.innerHTML; + + li.removeAttribute('checked'); + li.innerHTML = + `` + + ``; + }); + }); + + return doc.body.innerHTML; +} From 53970ecf9348d72b0f9d7ff652a01c597c6af44d Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 18 Jun 2026 17:26:15 +0200 Subject: [PATCH 05/15] fix: empty elements in list collapsing --- src/web/EnrichedText.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 2bcb2aa9..bee4e4d6 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -125,6 +125,7 @@ .eti-editor ul:not([data-type]) > li, .et-view ul:not([data-type]) > li { position: relative; + min-height: max(1lh, var(--et-ul-bullet-size)); } .eti-editor ul:not([data-type]) > li::before, @@ -159,6 +160,7 @@ .et-view ol > li { position: relative; counter-increment: eti-ol; + min-height: 1lh; } .eti-editor ol > li::before, @@ -265,6 +267,8 @@ position: relative; list-style: none; pointer-events: none; + min-height: max(1lh, var(--et-checkbox-box-size, 24px) + ); } .et-view ul[data-type="checkbox"] > li > p { From 9c649cbb0fdfca42b7c075d905481e711d978deb Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 18 Jun 2026 22:16:06 +0200 Subject: [PATCH 06/15] feat: html sanitization --- package.json | 3 ++- src/web/EnrichedText.tsx | 14 +++++++++---- src/web/EnrichedTextInput.tsx | 3 ++- src/web/{consts => constants}/classNames.ts | 0 .../prepareHtmlForWeb.ts} | 6 +++++- src/web/sanitization/htmlSanitizer.ts | 8 ++++++++ .../__tests__/buildMentionRulesCSS.test.ts | 2 +- .../styleConversion/buildMentionRulesCSS.ts | 2 +- yarn.lock | 20 +++++++++++++++++++ 9 files changed, 49 insertions(+), 9 deletions(-) rename src/web/{consts => constants}/classNames.ts (100%) rename src/web/{checkboxHtmlToWeb.ts => normalization/prepareHtmlForWeb.ts} (88%) create mode 100644 src/web/sanitization/htmlSanitizer.ts diff --git a/package.json b/package.json index 0fbf32ef..a14945ed 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,8 @@ "@tiptap/extension-underline": "3.20.4", "@tiptap/extensions": "3.20.4", "@tiptap/pm": "3.20.4", - "@tiptap/react": "3.20.4" + "@tiptap/react": "3.20.4", + "dompurify": "^3.4.11" }, "devDependencies": { "@commitlint/config-conventional": "^19.6.0", diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index b370356e..ffbfa1ad 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -3,10 +3,11 @@ import type { EnrichedTextProps } from '../types'; import './EnrichedText.css'; import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties'; import { htmlStyleToCSSVariables } from './styleConversion/htmlStyleToCSSVariables'; -import { ENRICHED_TEXT_CLASSNAME } from './consts/classNames'; +import { ENRICHED_TEXT_CLASSNAME } from './constants/classNames'; import { enrichedInputThemingToCSSProperties } from './styleConversion/enrichedInputThemingToCSSProperties'; import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS'; -import { checkboxHtmlToWeb } from './checkboxHtmlToWeb'; +import { sanitizeHtml } from './sanitization/htmlSanitizer'; +import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb'; export const EnrichedText = ({ children, @@ -14,7 +15,12 @@ export const EnrichedText = ({ style, selectionColor, }: EnrichedTextProps) => { - const html = useMemo(() => checkboxHtmlToWeb(children), [children]); + const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]); + + const finalHtml = useMemo( + () => prepareHtmlForWeb(sanitizedHtml), + [sanitizedHtml] + ); const textStyle: CSSProperties = useMemo( () => enrichedTextStyleToCSSProperties(style ?? {}), @@ -47,7 +53,7 @@ export const EnrichedText = ({
); diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index c5fa4bbe..5e9807a9 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -74,7 +74,8 @@ import { import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin'; import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin'; import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint'; -import { ENRICHED_TEXT_INPUT_CLASSNAME } from './consts/classNames'; +import { ENRICHED_TEXT_INPUT_CLASSNAME } from './constants/classNames'; + function runFocused( editor: Editor, apply: (chain: ChainedCommands) => ChainedCommands diff --git a/src/web/consts/classNames.ts b/src/web/constants/classNames.ts similarity index 100% rename from src/web/consts/classNames.ts rename to src/web/constants/classNames.ts diff --git a/src/web/checkboxHtmlToWeb.ts b/src/web/normalization/prepareHtmlForWeb.ts similarity index 88% rename from src/web/checkboxHtmlToWeb.ts rename to src/web/normalization/prepareHtmlForWeb.ts index e1bc8902..b79bc06c 100644 --- a/src/web/checkboxHtmlToWeb.ts +++ b/src/web/normalization/prepareHtmlForWeb.ts @@ -1,3 +1,7 @@ +export function prepareHtmlForWeb(html: string) { + return checkboxHtmlToWeb(html); +} + /* * Native checkbox format (as produced by the editor): *
    @@ -17,7 +21,7 @@ * *
*/ -export function checkboxHtmlToWeb(html: string): string { +function checkboxHtmlToWeb(html: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); diff --git a/src/web/sanitization/htmlSanitizer.ts b/src/web/sanitization/htmlSanitizer.ts new file mode 100644 index 00000000..d309cdd0 --- /dev/null +++ b/src/web/sanitization/htmlSanitizer.ts @@ -0,0 +1,8 @@ +import DOMPurify from 'dompurify'; + +export function sanitizeHtml(html: string) { + return DOMPurify.sanitize(html, { + ADD_TAGS: ['mention'], + ADD_ATTR: ['text', 'indicator'], + }); +} diff --git a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts index 1f0808e1..280ea23a 100644 --- a/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts +++ b/src/web/styleConversion/__tests__/buildMentionRulesCSS.test.ts @@ -3,7 +3,7 @@ import { buildMentionRulesCSS } from '../buildMentionRulesCSS'; import { ENRICHED_TEXT_CLASSNAME, ENRICHED_TEXT_INPUT_CLASSNAME, -} from '../../consts/classNames'; +} from '../../constants/classNames'; describe('buildMentionRulesCSS', () => { it.each([ diff --git a/src/web/styleConversion/buildMentionRulesCSS.ts b/src/web/styleConversion/buildMentionRulesCSS.ts index 2fa3c16e..e716de02 100644 --- a/src/web/styleConversion/buildMentionRulesCSS.ts +++ b/src/web/styleConversion/buildMentionRulesCSS.ts @@ -3,7 +3,7 @@ import { isMentionStyleRecord } from '../../utils/isMentionStyleRecord'; import { ENRICHED_TEXT_CLASSNAME, ENRICHED_TEXT_INPUT_CLASSNAME, -} from '../consts/classNames'; +} from '../constants/classNames'; import { ET_MENTION_CSS_VARS } from './htmlStyleToCSSVariables'; import { MENTION_STYLE_DEFAULT_KEY } from './mentionIndicatorCssKey'; diff --git a/yarn.lock b/yarn.lock index 3422547d..400f38f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4548,6 +4548,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:^2.0.7": + version: 2.0.7 + resolution: "@types/trusted-types@npm:2.0.7" + checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c + languageName: node + linkType: hard + "@types/use-sync-external-store@npm:^0.0.6": version: 0.0.6 resolution: "@types/use-sync-external-store@npm:0.0.6" @@ -6909,6 +6916,18 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:^3.4.11": + version: 3.4.11 + resolution: "dompurify@npm:3.4.11" + dependencies: + "@types/trusted-types": "npm:^2.0.7" + dependenciesMeta: + "@types/trusted-types": + optional: true + checksum: 10c0/31439481c7e8fc3805d40c376936fd66936620fb1b1a31a2ec097f6165412c37f2d868e082c9ceba62bb37661c1ea132a5db4d5213434317e30df68d4aca9cc9 + languageName: node + linkType: hard + "dot-prop@npm:^5.1.0": version: 5.3.0 resolution: "dot-prop@npm:5.3.0" @@ -12881,6 +12900,7 @@ __metadata: clang-format: "npm:^1.8.0" commitlint: "npm:^19.6.1" del-cli: "npm:^5.1.0" + dompurify: "npm:^3.4.11" eslint: "npm:^9.22.0" eslint-config-prettier: "npm:^10.1.1" eslint-plugin-prettier: "npm:^5.2.3" From cfc85ba69e636a1bd6aaa041f2f1d3442c1bdba1 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 19 Jun 2026 01:35:35 +0200 Subject: [PATCH 07/15] feat: image placeholder --- .../enriched-text-blockquote-codeblock.png | Bin 0 -> 3493 bytes .../enriched-text-checkbox-list-checked.png | Bin 0 -> 2244 bytes ...nriched-text-checkbox-list-empty-items.png | Bin 0 -> 2646 bytes .../enriched-text-checkbox-list-unchecked.png | Bin 0 -> 1977 bytes ...enriched-text-ordered-list-empty-items.png | Bin 0 -> 2982 bytes .../enriched-text-ordered-list.png | Bin 0 -> 3530 bytes .../screenshots/enriched-text-rich-text.png | Bin 0 -> 8998 bytes ...riched-text-unordered-list-empty-items.png | Bin 0 -> 2933 bytes .../enriched-text-unordered-list.png | Bin 0 -> 3736 bytes .playwright/tests/testEnrichedText.spec.ts | 100 ++++++++++++++++++ apps/example-web/src/RouteSelector.tsx | 5 + .../src/testScreens/TestEnrichedText.tsx | 53 ++++++++++ src/web/EnrichedText.css | 36 ++++++- src/web/EnrichedText.tsx | 99 +++++++++-------- src/web/normalization/prepareHtmlForWeb.ts | 27 ++++- src/web/sanitization/htmlSanitizer.ts | 2 +- 16 files changed, 268 insertions(+), 54 deletions(-) create mode 100644 .playwright/screenshots/enriched-text-blockquote-codeblock.png create mode 100644 .playwright/screenshots/enriched-text-checkbox-list-checked.png create mode 100644 .playwright/screenshots/enriched-text-checkbox-list-empty-items.png create mode 100644 .playwright/screenshots/enriched-text-checkbox-list-unchecked.png create mode 100644 .playwright/screenshots/enriched-text-ordered-list-empty-items.png create mode 100644 .playwright/screenshots/enriched-text-ordered-list.png create mode 100644 .playwright/screenshots/enriched-text-rich-text.png create mode 100644 .playwright/screenshots/enriched-text-unordered-list-empty-items.png create mode 100644 .playwright/screenshots/enriched-text-unordered-list.png create mode 100644 .playwright/tests/testEnrichedText.spec.ts create mode 100644 apps/example-web/src/testScreens/TestEnrichedText.tsx diff --git a/.playwright/screenshots/enriched-text-blockquote-codeblock.png b/.playwright/screenshots/enriched-text-blockquote-codeblock.png new file mode 100644 index 0000000000000000000000000000000000000000..ec05ddbabad4851abcc4decf59f8b53a13a13e6e GIT binary patch literal 3493 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 E4_fc=i~s-t literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/enriched-text-checkbox-list-empty-items.png b/.playwright/screenshots/enriched-text-checkbox-list-empty-items.png new file mode 100644 index 0000000000000000000000000000000000000000..50119fa1dc9ec96442e620028af57fcaec19ea73 GIT binary patch literal 2646 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 literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/enriched-text-ordered-list.png b/.playwright/screenshots/enriched-text-ordered-list.png new file mode 100644 index 0000000000000000000000000000000000000000..bd6d58de5d7fcb9dd829204e80ea13bde21efcb6 GIT binary patch literal 3530 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 DiU5G- literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/enriched-text-rich-text.png b/.playwright/screenshots/enriched-text-rich-text.png new file mode 100644 index 0000000000000000000000000000000000000000..2e5b924d106341845a4e1adcb7e6df1dec660f29 GIT binary patch literal 8998 zcmc(FcOcdO_x~-)Cz1vst9MGeC@Zq1B!t4XXJzlbFQqgjWo0Y--iyn%xAI1rg?sH0 z*?Vuk=j!wO@Au#LkMBQR_g=62e4XccKGwN!9;zzPQeU7(AP}^Q_wTAB5R~u`;qafM z@Kcg?`!xb_8liaiwx(zN?0}bVh{gWFQs0H=ug_YCETgdUn6zk86}??*kb9@$jvh!-(HEj`0@LK)I?&USatrGPH&y6sA3Cc>m8o z{~RuT`0LZN0V&DJ()*iF8rF;K`i@glGAEWgjn?Oy*3S2rKIt;TsHgpNCy1%%j;rfx z#5E1pzMb_s{givOE@FIqcB_-Wil(%+vh~s~IpVakPm({dhT{8W#8oaXb_w_9yh^=7 z>!1evfiebW=Be&^rZ)#Q*NrMBSCyuz^*^=6i=8`nZnWE?;XT{m5_-z^ADxrGZcs8@ zAZ1+T``DVxfsJ%gj=Tc)N&(_Ms4ZVU%&QQrP9_{O+NBT=$6i_6bHhx;j zqBS0kM!%$CBu)6y#f621Sw6}&`RY8@xV5>-RE_H^c9=1_ZdjJeqg7<9Yiw*hT6Ou# z6~hw8f5gN}H8JW}lp^@atGsBlJdKEtiYjs#s&bnl?ZGC(dJ6P$54+M;;-dKUj~_ec zDf!2Lu$+aRy~?7EJ!H1vPB@p!1wDH1hc$sqC)v8YjQuO&b2m3V%WH8O8LFY|QoDP5 zv~Apk_sl}~Eap7#&-E7OqxW`K+Bh#=n%$nXYzSeo&~e^uO^|StYzXBjp8Jf`OjC-~ zHufXddfeiW^41Kr9jY2AdUQ=M@E8@9sCDOe+pRz0YOV2NPl~gb{SWr{4okeqHOLYW ziJWMTNm)+8ow8_4OtAQsEKgI%$KWtP=Q%^lzEQO~lHVrnGBn3RlSV@CZCm|e6>~0` zsxWJcvg$||3vyXmylb&EU%nvXFxbBsK$!6u@|);3Ds#2!!#{fTXvqJdvg1r^k_>*p zs`@>ydD>M+M`xln!DoGTDqS`kTS6%4Fr0kA$(!giUwVY%IJf4)P_+yfSACAYoZR!n zO*X1$p;%0i@Gx&ntn^;9rrU8CC|g_nU3-q}L2jH(4{5CF6GIqVN-Pic;AlhWP^Hg? z@xgXie`$j86}I2+n1%X7Q~u55$H?k@k)s^dPVa}EH5Qd&j*hxvoa4xH;X>?HHTH%= z#_I-5w;Vrr{KroDwz#06Gj#7?s5oP^aUr#!pun^?$SV=8vb3~>wPu&{?wrz#6uu)T zC#hDgg``wV5WmUES?ffs4`C5fsn%MVXp?cDjb7r3lk{>5?eI-QOqa zJDA0yReC?Idv44dQjnX7a1>8d!=sh8`TG^eyB$IISsnRX4eRp3=e?MwS?#)Wzb;w* zw&}|8+g)m?uly;%R80t^qVpcqjWMXE>AneFDl8!IYcYDdX2Z{vT(e)2Dyxf*FGuc+Al#SA#<9$lrRv-AiGx>i zAFFN$9xu~^KRb@_8kGD*wVk`F+KnD4aY~6Z#c5*B($i}u-+cp6@Z!}ghaN&5xy}K+ zb-`R;)tO1embUP#YDIotYOv2ct5Ys()2KSbhE@spSR^@)TofU)dz zkKx#50wm;&zbx|cd!(ZijWdPoGo3yAc(1p_X%w)*TIcWfpSt0Fyc>mJk@hX=(C(^Q zX_X-2v^{LKox0a?p`mI46sR|B#w)lmtG*&TS_=SM6GL~DdepBJg}zF{eIE`{`}pU1 zOEVgC9*q&#-0^|!p_CLq}yS^OfP-WGXX?sx5;=n|%#|Jmh zN0gpWVAa03n>OPGQfi5kDsduGu9F`O%bf=zd9>fsUwe!O@Xh?4M=)y=w<|s_@3eMm z5RgaI@@Gw6rRUN}V@>t#$^yVGfJ8@IykU7!Ym-$~rSBF2ed^~oHJQCXt`Fol1fJC2 z@-q!<-Kcp+k&|=SIG!vwsB7Fk{^<3fT}Qrk3?gPPzE~SB7$7c@5qWP z2%a~kvAm5dU2}S+Z(VxjH8mrqqkJ~zE2}p)Qd3h)^v1B>w%*G>uKLeq=>VE*;hk^8FSFt-^e@Bx!bc)dvq;*O;)xLYuC4uO>$7g5CLk{rdIg z%a@J4AX6Ug28wQ-G)3;R zQK|EoD${=JWH`bTp^Lqj{_tZazbnkBC+K<%<_DD!gYUtxNf=pX8A4Zwas;ob;LR7ZVMalUGn+F=|F3q|v)2aoMU# z(styF%AWt&v3TK%a+Is>yVtKvd^U2NsxNVJCKSO2>_uuDT{EgEvD>~WiR&oy=3NKe z$`N@J92^`PDq0bZvr498y)}F*C0fz{lS9>3{eM>(JV;@HH@=Vo@$-ib_h2 z-(J#mw71)`L|XZ*O`Az;&aR#s#G-K0OaGozr8-GNBP6JiaxcWLuSl$jM-yi>cT0e9 z${AEjkO$7FvaG|M_y;&x)ULOnzjW>ZvpdRh&|U11bjVk_FBnk)al%$31%7|Nf6Qsj zNb=tYm}=7AKL9eNnTfAg<(RBQ@0E4@TAv^Ane7(f6r!Ha(F(b6b9#NQ&pjmHyv5m{ zGf#AJZ7tilx~c<(we{bg%5V?Co_rM)bSS6D2N#iJQ0fd?kF_s6EG+0G^JCFqLL-*i zB=|g^^Xw3?_)LV}`;{lmQC|5G@j`-vwk%y)x2|}SO2}kw~PZv`3B0|;4Z_S zlwY!ObaZTr;^RSKBN7wQ)1B8)7Y!@FWWcpZV{qd(`VG=6<1Kx8PZU7g!6V#qR@^UE z20lG9sWv`S?XoU7!+DQM_rvuC*aHJ8N;WIg!*+-oGdxlPfMjZFnnM^nPqV=(xzXKMe^zYR&v9kaxDrM*j(m3(JE#6^CHUoP~2>CE+^}MR=M-)l-rt8;scDd zq;)5E-yuQPP(Q%2p$@J>YocI<5&YaNfy3{Q*OC~3G7t+0I=^(O$nYVb1*vkzQ$9j$i*V2bpFcNn%QULo-rZV3;o2g3 zx&kZG4zUZZ93x3#tNpO0VBngWe8!wTbv|^VhCq?>r|ZW4duwHGGa!27-8GD$i>=7S ze4fv`L~=xD(JF^LO^baD7A$TxBCRZBU! zZ*4LM@nK=f9l!7=%z&~C%L-y9G&D34S_Q_ZpOULI>=7{v3L?m(++{-5z#u+(3S>dc z#x`;u8F($!%hMsBk2W$o+Hh1ROtdYmqGKp6--S{fiBbza^+~M z3{m~Mbfq5didQ}LHBKctIe8GhW7n{5-Vz(aEL2~^p zO}U?#O4=Sdf76B}Ovw81;R9Kp-y?Q2-Y}XKroeGYx=m|l+`&AwJ;a$$OZ653OXZ&9 zF&3;Wa2~f;r}ANg3krn$)+Z+?zXnp3ds{i~ZLj^+-GaKGkG>na2Pd6`H(uyYjE=tL zyXgongIG+%c-aH5!#Ckrll?`{9fyFWZNyPrGsXf|9@G)T|`hudq)R= zu%MtIfP}?GA5JoRU*t)4q*5Cr{MTGvI%ISNf%53zC-fI5Z{C0N<_*+VMOj%0O~=g4 z+{HUTim)H5%BBqu4^PNZQBk?6AcAHC13d_8aDAj?nv|^%_*C9Sl?z)#$}wbNWXu?Q z{rWWsoavE6C4`^@Tg_0cS8PA<<<6@vBcE(=H=t=$6cq)~`frEkcmbYIuVP=mbo2yeD$b;A^ZTfmSIXNTW zCHWbb4A57G)o4FZy6Qb(8v81JJcQx^0JlFXuwT<9fYIlH9Wq95nPqRUcBm0`Z9sLB zKsSTVhz%rT4kQ&HKYpZT5h0@;z=Z)@XwWtoSNf*Cy}g6g3>mli{+@=027^KfC>E-A z=0Q#NT4n1Mio6X(7 zNRChrnVhH};a6uSf*?Fd&&Zg$h_WH(0Qs#p^56?>d)jmRU$CAzbB3Ni)hye4p>o5O z30wfNYj0-*cThZp#_^;W45TT4lJr{E*+`QKch8{Q6RX-9k1HPZ#(}93weSCVV%ouD zsy&TMC3bi0hW|C~oPIi)U4EDG2MHhdPfz;;>Jej-(A!gR+qd4Ekat-&YGnTWBy<{2 zoZtS|1hulca*Uu^gk;P8-39*xA847pygU$MSr5NFFWZ}Q@YlHCfpwW^_1jzPqE1t4 z3YFO}d9IZt?YBPLlM;b~xTwyAQqrXcVi}MFn!O#Fgf<4o!bk@mr!4~$Ffi`1Ft|4yNaq@lEkXKT*CjlKh{1ln z5Zd)>lCL^ddU$)#IA0^2y!LUWP3pzQO6qn{)#Ib(jm-D zx=yf{+{jNzlv3aHu-(SmWJ+j}<*#qK^f3&hYb?)NEy3Mw^KkbgXRME*)&L|5G(s>h z{C|I=T%a3OdJ=+Ti z!cbp81SuwB-=FzK^Sy{mdSfc~0g@Rjj6I53o(niaVJq6D8o65V`P`!-O+Wp-9%3nO zRSKj{{wmfItMM?BsJE{9A7Lv0skBcW;M}p1ZY7P_r z8zCw(S=6@sAvh>QZa;w24*bdOYhNCv+}p->V@^@mC}GXN=*Vz0RZE^UfDr7;7O1pL zPicrJ@-uV72yds$=t;|-peo!xB>An!SdWeyX>Jk)W*)~Do8p)TaoRVC1#Y@Qv@C$X zqQqxzb|9Ejf30e3ag?7S_w2VCp7k+@e#_UO*W}2~IOC-;yOYyoUnuVlX$tLT1v89a-?FXp) zTA~f3aD0N9yXo7TZ84{95}uSASEI+Xf4y}PALj7i{blbmBiy9)9f%*eDt6swFmmob zQPXc9GB$0z(aoXcrxHVi^~cvE2>dvbxhxog>{6v_dSScXRUE#`nYCZiDL=|*Z`;jK zab~3ki75xSz+g?!puDcW7}Qb#HhJ$KwS)p;HyW@>DH;TVVrl9`N*%nV`OCZ~OB@+yVR z05P{;VBU2Rs7QCizKXj0WNk7{iQ{iML+ieMP!58;U$o_)gy5@RQV*M7(R!psHEgYr z6HWEG+qTcr@7fa0jf&6Hm0J`Ft?}%Y*A8tfMg63&7KZXu52PmZ&7Dy@x|_MbxypxTU!?GxsBJ4ji$ zUVJBk%&1XZ*s7(I5ngaC zGCOW)bGwgq0y>k#ga3)c61=*pE8(M9#GF(2&+8Z2I*C6w-FovcZ#7u}$VLqljv9QH zeFhE96M*5xNJc@90f{710PCt-dfPc~$nu3$hIh^`1wZWdTNWLF~ z5Cdte`A~F%Vg}6bfU4W2?HTTtX@?B9skfBJk7KI_@-5W%7+lvx?0TQ7&ZISE)+UO% zq`%z)1@q9)sH;>b@3A|lLU}1Vf^Bc*MJ0HeO6fEL5!Y+TbzG66Vd( zpgfU#$XhKjLJhpfk?Bamy83$1)>;@voTiwtgPe%%e8$aBb5*f47aHQ#l|MRkWAwA!^EIb1_?mUc6_}RS9QAm!F(J?&DhPAHS7LeN^ zF8l}BF?XNA7WSUcVXy)@#Q~wB$=Bx>i~~VS=xd{4HIUkvJ38i~M}MkOV0Urf@k>;+ z7gCk2tB!QrAFEwn%y%y7m}(>!uZCtUSJm&&TVxI7@yK>8ua!h?cqDZwlCe+9(MJs| z#dYJVEJVzdOHt4myP=zl_hn=bGE&UYYzCSZrbwhM+yF;pnVI-!HW!km;kh=^Ut_2u zVWI3&Gr}>x4hj`XfatmWN@#3AyF&5Pu0myaZzf*RWA z)9uhsE+$`O`Kt3)LfD)n!WJ$we%Gimh>HkIUBxx;;u-f*U-8d7&QQ~HFM`>g(O4=3 zY=ga0uiOAT1p?l#BfW*3Y-RvY`)#j2`r-+Weq0L*%OPy}p)_kBWQfan^N%DM|9ki9 zVeZh$$Ibm6JQR6T5l`u2Gy`A~JwG!;9uf1w%;J6$#BKGJ-1@m$G3YeKDX#5zWvpiC z$|&#b+Ag+{@X@Y{+s`Ji}VJx&lV0*K@ zw}6{J8CShxP7EIBu{K2iJ8`BDrLCSb>Cc(Jl}DaU7_P7EEcEL&7Hr2$`4kyWz;r2= zrg3gu4l*O@>ipa}%sQ0m?`(MXt07{r+yh@qR**I+)K~}+`1FgimiQmo-Ev!;|2k0Q z*0Y zNP?VxDk*7*^sqYDZLZM&UM517V~9X1do@X|VPbhA%t;b~UZ3K&oyZp7lN@#XWD`LWijzGgV^F^%!$Idl1j zjx*B%O%my%bziiQf@(&>RncfxuWQcg|E7x9&2(~W)uOvuTZ39YK7qJTT3VVsiLdf4 z)g!BWys=CzbGDMIsu8qrciw^5i4F)bvh7yJnli@ZUQ#pnpE``GdSu_jD33{37upqs z+1~E1Er+K27+BlJX7klTC1vIOY-ft^5nCt8(aDEG$rr=L|Crx7$lb56<;SjP0?hR{ zojbzE2Zl$xhcNrU`Zmbo0bKatz;g&S g2oH{v+nEA!Wz)LV>y!5fcm|;;uX;B}&gA+30FJV(-2eap literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/enriched-text-unordered-list-empty-items.png b/.playwright/screenshots/enriched-text-unordered-list-empty-items.png new file mode 100644 index 0000000000000000000000000000000000000000..1f574d1daf6728da714e0536b2cd56fd75422c75 GIT binary patch literal 2933 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 literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/enriched-text-unordered-list.png b/.playwright/screenshots/enriched-text-unordered-list.png new file mode 100644 index 0000000000000000000000000000000000000000..5aadcca4147c5650d58380aff28ec633e4d44e38 GIT binary patch literal 3736 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 E12Nh7SO5S3 literal 0 HcmV?d00001 diff --git a/.playwright/tests/testEnrichedText.spec.ts b/.playwright/tests/testEnrichedText.spec.ts new file mode 100644 index 00000000..20da54e3 --- /dev/null +++ b/.playwright/tests/testEnrichedText.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, type Locator, 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', +} as const; + +function displayLocator(page: Page): Locator { + return page.locator(sel.display); +} + +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); + // The display mirrors the value through the output node; wait until applied. + await expect + .poll(async () => (await page.locator(sel.valueOutput).textContent()) ?? '') + .toBe(html); +} + +test.describe('EnrichedText display visual regression', () => { + const cases: { name: string; snapshot: string; html: string }[] = [ + { + name: 'rich text: heading, bold, italic and link', + snapshot: 'enriched-text-rich-text.png', + html: [ + '', + '

Heading

', + '

Some bold and italic text.

', + '

A link here.

', + '', + ].join(''), + }, + { + name: 'unordered list', + snapshot: 'enriched-text-unordered-list.png', + html: '
  • Alpha
  • Beta
  • Gamma
', + }, + { + name: 'unordered list with empty items', + snapshot: 'enriched-text-unordered-list-empty-items.png', + html: '
  • Alpha
  • Gamma
', + }, + { + name: 'ordered list', + snapshot: 'enriched-text-ordered-list.png', + html: '
  1. One
  2. Two
  3. Three
', + }, + { + name: 'ordered list with empty items', + snapshot: 'enriched-text-ordered-list-empty-items.png', + html: '
  1. One
  2. Three
', + }, + { + name: 'checkbox list all unchecked', + snapshot: 'enriched-text-checkbox-list-unchecked.png', + html: '
  • one
  • two
', + }, + { + name: 'checkbox list with checked item', + snapshot: 'enriched-text-checkbox-list-checked.png', + html: '
  • one
  • two
', + }, + { + name: 'checkbox list with empty items', + snapshot: 'enriched-text-checkbox-list-empty-items.png', + html: '
  • one
  • three
', + }, + { + name: 'blockquote and codeblock', + snapshot: 'enriched-text-blockquote-codeblock.png', + html: [ + '', + '

Quoted line

', + '

const a = 1;

', + '', + ].join(''), + }, + ]; + + for (const c of cases) { + test(c.name, async ({ page }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue(page, c.html); + + await expect(displayLocator(page)).toHaveScreenshot(c.snapshot); + }); + } +}); diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index d04f3d2c..c31df8dd 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -4,6 +4,7 @@ import { TestLinks } from './testScreens/TestLinks'; import { TestSetSelection } from './testScreens/TestSetSelection'; import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; +import { TestEnrichedText } from './testScreens/TestEnrichedText'; import { useEffect, useState } from 'react'; export default function RouteSelector() { @@ -40,5 +41,9 @@ export default function RouteSelector() { return ; } + if (path === '/test-enriched-text') { + return ; + } + return ; } diff --git a/apps/example-web/src/testScreens/TestEnrichedText.tsx b/apps/example-web/src/testScreens/TestEnrichedText.tsx new file mode 100644 index 00000000..9c8e3f9a --- /dev/null +++ b/apps/example-web/src/testScreens/TestEnrichedText.tsx @@ -0,0 +1,53 @@ +import { useState, type ChangeEvent } from 'react'; +import { EnrichedText } from 'react-native-enriched-html'; +import type { TextStyle } from 'react-native'; +import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; + +const INITIAL_VALUE = '

'; + +export function TestEnrichedText() { + const [htmlInput, setHtmlInput] = useState(INITIAL_VALUE); + const [value, setValue] = useState(INITIAL_VALUE); + + return ( +
+
+ + {value} + +
+ +