diff --git a/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml b/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml new file mode 100644 index 000000000..04ad337d1 --- /dev/null +++ b/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml @@ -0,0 +1,118 @@ +appId: swmansion.enriched.example +--- +# Validates custom colors on plain text, inline styles, and paragraph styles. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'editor-input' + +# Section 1: Plain text with colors + +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- inputText: 'Red text' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Yellow back' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Red+Yellow' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-clear' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +# Section 2: Inline styles + color + +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- inputText: 'Bold red' +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Italic yellow back' +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' +- pressKey: Enter + +# Section 3: Paragraph styles + color + +- tapOn: + id: 'toolbar-heading-5' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- inputText: 'H5 red' +- pressKey: Enter +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-clear' + +- tapOn: + id: 'toolbar-quote' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Quote yellow back' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-clear' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_colors' diff --git a/.maestro/enrichedInput/screenshots/android/codeblock_no_link_detection.png b/.maestro/enrichedInput/screenshots/android/codeblock_no_link_detection.png index 22acdaf60..78e27577d 100644 Binary files a/.maestro/enrichedInput/screenshots/android/codeblock_no_link_detection.png and b/.maestro/enrichedInput/screenshots/android/codeblock_no_link_detection.png differ diff --git a/.maestro/enrichedInput/screenshots/android/codeblock_style_blocking.png b/.maestro/enrichedInput/screenshots/android/codeblock_style_blocking.png index ad4efbb28..44ae70724 100644 Binary files a/.maestro/enrichedInput/screenshots/android/codeblock_style_blocking.png and b/.maestro/enrichedInput/screenshots/android/codeblock_style_blocking.png differ diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png index 75979ebea..4d075df95 100644 Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png b/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png new file mode 100644 index 000000000..e8e39c715 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png differ diff --git a/.maestro/enrichedText/flows/custom_style_colors_visual.yaml b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml new file mode 100644 index 000000000..e304b5b94 --- /dev/null +++ b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml @@ -0,0 +1,28 @@ +appId: swmansion.enriched.example +--- +# Validates that custom style colors are displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +

Standard 6-digit Hex text

+

White text on black background

+

25% transparent green background

+

50% transparent blue text

+

Red 3-digit shorthand text

+
Black text on green
+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_colors_visual' diff --git a/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png new file mode 100644 index 000000000..fc417d9b2 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png differ diff --git a/.maestro/scripts/setup-android-emulator.sh b/.maestro/scripts/setup-android-emulator.sh index 3c74de08d..605c8f6bd 100755 --- a/.maestro/scripts/setup-android-emulator.sh +++ b/.maestro/scripts/setup-android-emulator.sh @@ -77,6 +77,7 @@ until adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | grep -q "^ done adb -s "$SERIAL" shell pm disable-user --user 0 com.google.android.inputmethod.latin +adb -s "$SERIAL" shell settings put secure spell_checker_enabled 0 echo "Emulator ready: $AVD_NAME ($SERIAL)" echo "DEVICE_ID=$SERIAL" diff --git a/.maestro/scripts/setup-ios-simulator.sh b/.maestro/scripts/setup-ios-simulator.sh index 66f4cb743..5f9d8ea23 100755 --- a/.maestro/scripts/setup-ios-simulator.sh +++ b/.maestro/scripts/setup-ios-simulator.sh @@ -25,6 +25,21 @@ if [ -z "$UDID" ]; then exit 1 fi +# disable automatic text manipulation: auto-correction, spelling-check and auto-capitalization +SIM_PREFS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Preferences" +mkdir -p "$SIM_PREFS_DIR" + +PLIST="$SIM_PREFS_DIR/com.apple.keyboard.preferences.plist" + +/usr/libexec/PlistBuddy -c "Add :KeyboardAutocorrection bool false" "$PLIST" 2>/dev/null || \ +/usr/libexec/PlistBuddy -c "Set :KeyboardAutocorrection bool false" "$PLIST" + +/usr/libexec/PlistBuddy -c "Add :KeyboardCheckSpelling bool false" "$PLIST" 2>/dev/null || \ +/usr/libexec/PlistBuddy -c "Set :KeyboardCheckSpelling bool false" "$PLIST" + +/usr/libexec/PlistBuddy -c "Add :KeyboardAutocapitalization bool false" "$PLIST" 2>/dev/null || \ +/usr/libexec/PlistBuddy -c "Set :KeyboardAutocapitalization bool false" "$PLIST" + STATE=$(xcrun simctl list devices | grep "$UDID" | grep -oE '\(Booted\)|\(Shutdown\)' || true) if [ "$STATE" != "(Booted)" ]; then echo "Booting '$DEVICE_NAME' ($UDID)..." diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt new file mode 100644 index 000000000..d9a0ddb50 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt @@ -0,0 +1,39 @@ +package com.swmansion.enriched.common + +import android.text.Spannable +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan + +// Higher priority spans are processed first, so styles with lower priorities are painted on top of previously applied styles. +// For example, inline styles are applied on top of paragraph styles, allowing them to override paragraph-level styling. +// Alignment styles are applied last, ensuring they position the final, fully styled text. +object EnrichedSpanFlags { + private const val ALIGNMENT_SPAN_PRIORITY = 0 + private const val INLINE_SPAN_PRIORITY = 1 + private const val PARAGRAPH_SPAN_PRIORITY = 2 + + @JvmStatic + @JvmOverloads + fun forSpan( + span: Any?, + baseFlags: Int = Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ): Int { + val priority = + when (span) { + is EnrichedAlignmentSpan -> ALIGNMENT_SPAN_PRIORITY + is EnrichedInlineSpan -> INLINE_SPAN_PRIORITY + else -> PARAGRAPH_SPAN_PRIORITY + } + return applyPriority(baseFlags, priority) + } + + private fun applyPriority( + flags: Int, + priority: Int, + ): Int { + // Cleaning up priority bits + val cleared = flags and Spannable.SPAN_PRIORITY.inv() + // Injecting priority bits + return cleared or ((priority shl Spannable.SPAN_PRIORITY_SHIFT) and Spannable.SPAN_PRIORITY) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 02d52f706..08198b3c6 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java @@ -7,6 +7,7 @@ import android.text.TextUtils; import android.text.style.ParagraphStyle; import com.swmansion.enriched.common.EnrichedConstants; +import com.swmansion.enriched.common.EnrichedSpanFlags; import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan; import com.swmansion.enriched.common.spans.EnrichedBoldSpan; import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan; @@ -467,10 +468,7 @@ public Spanned convert() { if (end == start) { mSpannableStringBuilder.removeSpan(obj[i]); } else { - // TODO: verify if Spannable.SPAN_EXCLUSIVE_EXCLUSIVE does not break anything. - // Previously it was SPAN_PARAGRAPH. I've changed that in order to fix ranges for list - // items. - mSpannableStringBuilder.setSpan(obj[i], start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + mSpannableStringBuilder.setSpan(obj[i], start, end, EnrichedSpanFlags.forSpan(obj[i])); } } @@ -505,7 +503,7 @@ public Spanned convert() { mSpannableStringBuilder.removeSpan(zeroWidthSpaceSpan); mSpannableStringBuilder.setSpan( - zeroWidthSpaceSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + zeroWidthSpaceSpan, start, end, EnrichedSpanFlags.forSpan(zeroWidthSpaceSpan)); } return mSpannableStringBuilder; @@ -802,7 +800,7 @@ private static void setSpanFromMark(Spannable text, Object mark, Object... spans int len = text.length(); if (where != len) { for (Object span : spans) { - text.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span, where, len, EnrichedSpanFlags.forSpan(span)); } } } @@ -825,7 +823,7 @@ private static void setParagraphSpanFromMark(Editable text, Object mark, Object. if (where != len) { for (Object span : spans) { - text.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span, where, len, EnrichedSpanFlags.forSpan(span)); } } } @@ -850,11 +848,9 @@ private static void startImg( int len = text.length(); text.append(""); - text.setSpan( - spanFactory.createImageSpan(src, Integer.parseInt(width), Integer.parseInt(height)), - len, - text.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + Object imageSpan = + spanFactory.createImageSpan(src, Integer.parseInt(width), Integer.parseInt(height)); + text.setSpan(imageSpan, len, text.length(), EnrichedSpanFlags.forSpan(imageSpan)); } private static void startA(Editable text, Attributes attributes) { diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt index d3133ee8c..53a0a503a 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -23,6 +23,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser import com.swmansion.enriched.common.pixelFromSpOrDp @@ -277,7 +278,7 @@ class EnrichedTextView : AppCompatTextView { spannable.removeSpan(span) val newSpan = span.rebuildWithStyle(enrichedStyle) - spannable.setSpan(newSpan, start, end, flags) + spannable.setSpan(newSpan, start, end, EnrichedSpanFlags.forSpan(newSpan, flags)) modified = true } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index cdcad4e2f..b2a0c16c4 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -38,6 +38,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser import com.swmansion.enriched.common.pixelFromSpOrDp @@ -1108,7 +1109,7 @@ class EnrichedTextInputView : spannable.removeSpan(span) val newSpan = span.rebuildWithStyle(htmlStyle) - spannable.setSpan(newSpan, start, end, flags) + spannable.setSpan(newSpan, start, end, EnrichedSpanFlags.forSpan(newSpan, flags)) } if (shouldEmitStateChange) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 9dcbb8244..3cb4a5e86 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -479,6 +479,13 @@ class EnrichedTextInputViewManager : view?.setTextAlignment(alignment) } + override fun setStyle( + view: EnrichedTextInputView?, + styleJSON: String, + ) { + // TODO: Implement + } + override fun measure( context: Context, localData: ReadableMap?, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt index d7df95109..e7353f677 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt @@ -4,6 +4,7 @@ import android.text.Editable import android.text.Spannable import android.text.SpannableStringBuilder import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan @@ -24,12 +25,8 @@ class AlignmentStyles( flags: Int = Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, ) { val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) - spannable.setSpan( - EnrichedInputAlignmentSpan(cssValue), - safeStart, - safeEnd, - flags, - ) + val span = EnrichedInputAlignmentSpan(cssValue) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span, flags)) } private fun toCssValue(alignment: String): String = @@ -302,7 +299,9 @@ class AlignmentStyles( // INCLUSIVE_EXCLUSIVE is intentional here: autoStretchAlignmentSpan will convert // it to EXCLUSIVE_EXCLUSIVE once the merge is complete. val (safeStart, safeEnd) = s.getSafeSpanBoundaries(paraStart, paraEnd) - dominantTopSpan?.let { s.setSpan(it, safeStart, safeEnd, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) } + dominantTopSpan?.let { + s.setSpan(it, safeStart, safeEnd, EnrichedSpanFlags.forSpan(it, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)) + } return cursorPosition } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt index 26c871cec..802f78f1b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.textinput.styles import android.text.Editable import android.text.Spannable +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries @@ -41,7 +42,7 @@ class InlineStyles( val span = type.getDeclaredConstructor(HtmlStyle::class.java).newInstance(view.htmlStyle) val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(minimum, maximum) - spannable.setSpan(span, safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) } private fun setAndMergeSpans( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt index 7bce4dcd4..236560f10 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt @@ -5,6 +5,7 @@ import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan @@ -66,18 +67,18 @@ class ListStyles( when (name) { EnrichedSpans.UNORDERED_LIST -> { val span = EnrichedInputUnorderedListSpan(view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) } EnrichedSpans.ORDERED_LIST -> { val index = getOrderedListIndex(spannable, safeStart) val span = EnrichedInputOrderedListSpan(index, view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) } EnrichedSpans.CHECKBOX_LIST -> { val span = EnrichedInputCheckboxListSpan(isChecked ?: false, view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height view.layoutManager.invalidateLayout() diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt index b7d46637f..af822b7fe 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt @@ -4,6 +4,7 @@ import android.text.Editable import android.text.Spannable import android.text.SpannableStringBuilder import android.util.Log +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan @@ -89,7 +90,7 @@ class ParagraphStyles( } val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(newStart, newEnd) - spannable.setSpan(span, safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) } private fun setSpan( @@ -105,7 +106,7 @@ class ParagraphStyles( val span = type.getDeclaredConstructor(HtmlStyle::class.java).newInstance(view.htmlStyle) val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) - spannable.setSpan(span, safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) } // Removes spans of the given type in the specified range. @@ -232,7 +233,7 @@ class ParagraphStyles( val (safeStart, safeEnd) = s.getSafeSpanBoundaries(newStart, newEnd) val span = type.getDeclaredConstructor(HtmlStyle::class.java).newInstance(view.htmlStyle) - s.setSpan(span, safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + s.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) } private fun handleConflictsDuringNewlineDeletion( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt index be6265b7d..c441224ba 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParametrizedStyles.kt @@ -5,6 +5,7 @@ import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedInputImageSpan import com.swmansion.enriched.textinput.spans.EnrichedInputLinkSpan @@ -63,7 +64,7 @@ class ParametrizedStyles( val spanEnd = start + text.length val span = EnrichedInputLinkSpan(url, view.htmlStyle, true) val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, spanEnd) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) view.selection?.validateStyles() isSettingLinkSpan = false @@ -160,7 +161,7 @@ class ParametrizedStyles( span, safeStart, safeEnd, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + EnrichedSpanFlags.forSpan(span), ) } } @@ -373,7 +374,7 @@ class ParametrizedStyles( val span = EnrichedInputMentionSpan(text, indicator, attributes, view.htmlStyle) val spanEnd = start + text.length val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, spanEnd) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(span, safeStart, safeEnd, EnrichedSpanFlags.forSpan(span)) val hasSpaceAtTheEnd = spannable.length > safeEnd && spannable[safeEnd] == ' ' if (!hasSpaceAtTheEnd) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt index 60ebd1a14..576e10218 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt @@ -3,6 +3,7 @@ package com.swmansion.enriched.textinput.utils import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.spans.interfaces.EnrichedBlockSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedParagraphSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedSpan @@ -144,7 +145,7 @@ fun Spannable.mergeSpannables( val (_, newParagraphEnd) = builder.getParagraphBounds(spanStart, pasteEnd) val flags = builder.getSpanFlags(span) builder.removeSpan(span) - builder.setSpan(span, spanStart, newParagraphEnd, flags) + builder.setSpan(span, spanStart, newParagraphEnd, EnrichedSpanFlags.forSpan(span, flags)) } } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt index 5f378a321..57f265b12 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt @@ -7,6 +7,7 @@ import android.text.Spanned import android.util.Log import android.view.MotionEvent import android.widget.TextView +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import org.json.JSONObject @@ -78,7 +79,7 @@ fun TextView.setCheckboxClickListener() { // Reapply span so changes are visible without need to redraw entire TextView spannable.removeSpan(span) - spannable.setSpan(span, start, end, flags) + spannable.setSpan(span, start, end, EnrichedSpanFlags.forSpan(span, flags)) // For focused input, ensure cursor is active for affected paragraph if (tv.isFocused) { diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index c5809f625..8ad87c255 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -261,6 +261,7 @@ function App() { onMentionDetected={handleOnMentionDetected} mentionIndicators={['@', '#']} htmlStyle={WEB_DEFAULT_HTML_STYLE} + useHtmlNormalizer /> void; + onClear: () => void; +} + +export const ColorPickerRow: FC = ({ + colors, + activeColor, + onSelectColor, + onClear, +}) => { + return ( + + + + + {colors.map((color) => { + const isActive = color.toLowerCase() === activeColor?.toLowerCase(); + const swatchId = `color-swatch-${color.replace('#', '').toUpperCase()}`; + return ( + onSelectColor(color)} + style={[ + styles.swatch, + { backgroundColor: color }, + isActive && styles.swatchActive, + color === '#FFFFFF' && styles.swatchBordered, + ]} + /> + ); + })} + + ); +}; + +const SWATCH_SIZE = 28; + +const styles = StyleSheet.create({ + container: { + width: '100%', + backgroundColor: 'rgba(0, 26, 114, 0.9)', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 8, + gap: 8, + }, + clearButton: { + width: SWATCH_SIZE, + height: SWATCH_SIZE, + borderRadius: SWATCH_SIZE / 2, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + clearText: { + color: 'white', + fontSize: 14, + lineHeight: 16, + }, + swatch: { + width: SWATCH_SIZE, + height: SWATCH_SIZE, + borderRadius: SWATCH_SIZE / 2, + }, + swatchActive: { + borderWidth: 3, + borderColor: 'white', + }, + swatchBordered: { + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.25)', + }, +}); diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 20109e390..c44d4d5d8 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -1,5 +1,14 @@ -import { FlatList, type ListRenderItemInfo, StyleSheet } from 'react-native'; +import { useState } from 'react'; +import { + FlatList, + type ListRenderItemInfo, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; import { ToolbarButton } from './ToolbarButton'; +import { ColorPickerRow } from './ColorPickerRow'; import type { OnChangeStateEvent, EnrichedTextInputInstance, @@ -8,6 +17,25 @@ import type { FC } from 'react'; const GRID_COLUMNS = 8; +const COLORS = [ + '#000000', + '#FFFFFF', + '#808080', + '#FF0000', + '#FF6600', + '#FFFF00', + '#00FF00', + '#008000', + '#00FFFF', + '#0000FF', + '#800080', + '#FF00FF', + '#FF69B4', + '#A52A2A', + '#FFA500', + '#ADD8E6', +]; + const STYLE_ITEMS = [ { name: 'bold', @@ -97,10 +125,19 @@ const STYLE_ITEMS = [ name: 'align-right', icon: 'align-right', }, + { + name: 'text-color', + text: 'A', + }, + { + name: 'bg-color', + text: 'BG', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; type StylesState = OnChangeStateEvent; +type OpenPicker = 'text-color' | 'bg-color' | null; export interface ToolbarProps { stylesState: StylesState; @@ -117,6 +154,20 @@ export const Toolbar: FC = ({ onSelectImage, layout = 'horizontal', }) => { + const [openPicker, setOpenPicker] = useState(null); + + const activeFgColor = stylesState.customStyle?.foregroundColor ?? ''; + const activeBgColor = stylesState.customStyle?.backgroundColor ?? ''; + + const fgIndicatorColor = + activeFgColor.length > 0 ? activeFgColor : 'transparent'; + const fgIndicatorBorder = + activeFgColor.length > 0 ? activeFgColor : 'rgba(255,255,255,0.4)'; + const bgIndicatorColor = + activeBgColor.length > 0 ? activeBgColor : 'transparent'; + const bgIndicatorBorder = + activeBgColor.length > 0 ? activeBgColor : 'rgba(255,255,255,0.4)'; + const handlePress = (item: Item) => { const currentRef = editorRef?.current; if (!currentRef) return; @@ -168,7 +219,6 @@ export const Toolbar: FC = ({ editorRef.current?.toggleOrderedList(); break; case 'checkbox-list': - // Make checkbox checked by default editorRef.current?.toggleCheckboxList(true); break; case 'link': @@ -289,6 +339,62 @@ export const Toolbar: FC = ({ }; const renderItem = ({ item }: ListRenderItemInfo) => { + if (item.name === 'text-color') { + return ( + + setOpenPicker((prev) => + prev === 'text-color' ? null : 'text-color' + ) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + openPicker === 'text-color' && styles.colorButtonActive, + ]} + > + A + + + ); + } + + if (item.name === 'bg-color') { + return ( + + setOpenPicker((prev) => (prev === 'bg-color' ? null : 'bg-color')) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + openPicker === 'bg-color' && styles.colorButtonActive, + ]} + > + BG + + + ); + } + return ( = ({ const keyExtractor = (item: Item) => item.name; + const handleSelectFgColor = (color: string) => { + editorRef?.current?.setStyle({ foregroundColor: color }); + setOpenPicker(null); + }; + + const handleClearFgColor = () => { + editorRef?.current?.setStyle({ foregroundColor: null }); + setOpenPicker(null); + }; + + const handleSelectBgColor = (color: string) => { + editorRef?.current?.setStyle({ backgroundColor: color }); + setOpenPicker(null); + }; + + const handleClearBgColor = () => { + editorRef?.current?.setStyle({ backgroundColor: null }); + setOpenPicker(null); + }; + return ( - + + + {openPicker === 'text-color' && ( + + )} + {openPicker === 'bg-color' && ( + + )} + ); }; const styles = StyleSheet.create({ - container: { + wrapper: { + width: '100%', + }, + list: { width: '100%', }, gridItem: { flexBasis: `${100 / GRID_COLUMNS}%`, aspectRatio: 1, }, + colorButton: { + justifyContent: 'center', + alignItems: 'center', + width: 56, + height: 56, + backgroundColor: 'rgba(0, 26, 114, 0.8)', + gap: 2, + }, + colorButtonActive: { + backgroundColor: 'rgb(0, 26, 114)', + }, + colorButtonLabel: { + color: 'white', + fontSize: 15, + fontWeight: '700', + lineHeight: 17, + }, + colorIndicator: { + width: 20, + height: 5, + borderRadius: 2, + borderWidth: 1, + }, }); diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts index 2b9d44b0b..092911f9e 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -33,6 +33,10 @@ export const DEFAULT_STYLES: StylesState = { mention: DEFAULT_STYLE_STATE, checkboxList: DEFAULT_STYLE_STATE, alignment: 'auto', + customStyle: { + foregroundColor: '', + backgroundColor: '', + }, }; export const DEFAULT_LINK_STATE = { diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index ceb9898dc..52af2af56 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -643,9 +643,9 @@ If true, Android will use experimental synchronous events. This will prevent fro If true, external HTML pasted/inserted into the input (e.g. from Google Docs, Word, or web pages) will be normalized into the canonical tag subset that the enriched parser understands. However, this is an experimental feature, which has not been thoroughly tested. We may decide to enable it by default in a future release. -| Type | Default Value | Platform | -| ------ | ------------- | ------------ | -| `bool` | `false` | iOS, Android | +| Type | Default Value | Platform | +| ------ | ------------- | ----------------- | +| `bool` | `false` | iOS, Android, Web | ## Ref Methods diff --git a/docs/WEB.md b/docs/WEB.md index 1eb07a7ab..0b1f167cf 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -16,6 +16,7 @@ Web support is still experimental. APIs and behavior can change in future releas - Submit props: `submitBehavior` and `onSubmitEditing`. `returnKeyType` is only a hint, it maps to [enterkeyhint](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint) (`done`, `go`, `next`, `previous`, `search`, `send`, `default`/`enter`). Not all values of `ReturnKeyTypeOptions` are supported, the behavior of this prop is heavily dependent on the browser's capabilities. - Input theming via `placeholderTextColor`, `cursorColor` and `selectionColor` props - Keyboard shortcuts for formatting +- `useHtmlNormalizer` ## Keyboard shortcuts @@ -26,7 +27,6 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo - **`returnKeyLabel`**: ignored on web, it's not possible to set it inside a browser. - **Automatic link detection**: `linkRegex` is ignored. Links only work when set explicitly via the `setLink` ref method. - **Context menu**: `contextMenuItems` is ignored. -- **HTML normalizer flag**: `useHtmlNormalizer` is ignored; paste behavior follows the browser pipeline. - **RN layout ref methods**: `measure`, `measureInWindow`, `measureLayout`, and `setNativeProps` are no-ops. - **`EnrichedText`**: The read-only component is not exported on web. - **`ViewProps`**: Props inherited from `View` beyond the implemented subset are not forwarded. diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 21ff1b721..f8bb2f778 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,6 +1,7 @@ #import "EnrichedTextInputView.h" #import "AlignmentUtils.h" #import "AttachmentLayoutUtils.h" +#import "ColorExtension.h" #import "CoreText/CoreText.h" #import "DotReplacementUtils.h" #import "HtmlParser.h" @@ -19,6 +20,7 @@ #import "WordsUtils.h" #import "ZeroWidthSpaceUtils.h" #import +#import #import #import #import @@ -62,6 +64,7 @@ @implementation EnrichedTextInputView { NSString *_submitBehavior; NSDictionary *_capturedAttributesBeforeChange; NSString *_recentlyEmittedAlignment; + CustomStyleData *_recentlyEmittedCustomStyle; } @synthesize blockEmitting = blockEmitting; @@ -1120,6 +1123,15 @@ - (void)tryUpdatingActiveStyles { updateNeeded = YES; } + // detect custom style change + CustomStyle *customStyle = stylesDict[@([CustomStyle getType])]; + CustomStyleData *currentCustomStyle = + [customStyle getCustomStyleDataAt:textView.selectedRange.location]; + if (currentCustomStyle != _recentlyEmittedCustomStyle && + ![currentCustomStyle isEqual:_recentlyEmittedCustomStyle]) { + updateNeeded = YES; + } + if (updateNeeded) { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { @@ -1127,6 +1139,7 @@ - (void)tryUpdatingActiveStyles { _activeStyles = newActiveStyles; _blockedStyles = newBlockedStyles; _recentlyEmittedAlignment = currentAlignment; + _recentlyEmittedCustomStyle = currentCustomStyle; emitter->onChangeState( {.bold = GET_STYLE_STATE([BoldStyle getType]), @@ -1148,7 +1161,14 @@ - (void)tryUpdatingActiveStyles { .codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]), .image = GET_STYLE_STATE([ImageStyle getType]), .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]), - .alignment = [currentAlignment UTF8String]}); + .alignment = [currentAlignment UTF8String], + .customStyle = { + .foregroundColor = + [[currentCustomStyle.foregroundColor hexString] UTF8String] + ?: "", + .backgroundColor = + [[currentCustomStyle.backgroundColor hexString] UTF8String] + ?: ""}}); } } @@ -1296,6 +1316,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { if (!_placeholderLabel.isHidden) { [self refreshPlaceholderLabelStyles]; } + } else if ([commandName isEqualToString:@"setStyle"]) { + NSString *styleJSON = (NSString *)args[0]; + [self setStyle:styleJSON]; } } @@ -1503,6 +1526,53 @@ - (void)toggleRegularStyle:(StyleType)type { } } +- (void)setStyle:(NSString *)styleJSON { + NSData *jsonData = [styleJSON dataUsingEncoding:NSUTF8StringEncoding]; + if (jsonData == nil) + return; + id parsed = [NSJSONSerialization JSONObjectWithData:jsonData + options:0 + error:nil]; + if (![parsed isKindOfClass:[NSDictionary class]]) + return; + NSDictionary *dict = (NSDictionary *)parsed; + + NSRange selectedRange = textView.selectedRange; + CustomStyle *customStyleClass = + (CustomStyle *)stylesDict[@([CustomStyle getType])]; + if (customStyleClass == nil) + return; + + if (![StyleUtils handleStyleBlocksAndConflicts:[CustomStyle getType] + range:selectedRange + forHost:self]) { + return; + } + + // Convert raw JSON values (NSNumber ARGB integers from processColor) to + // UIColor. NSNull is passed through as-is so mergeFromDict: can clear + // the color when the caller explicitly passes null. + NSMutableDictionary *processedDict = [NSMutableDictionary new]; + + id fgRaw = dict[@"foregroundColor"]; + if (fgRaw != nil) { + processedDict[@"foregroundColor"] = [fgRaw isKindOfClass:[NSNull class]] + ? [NSNull null] + : [RCTConvert UIColor:fgRaw]; + } + + id bgRaw = dict[@"backgroundColor"]; + if (bgRaw != nil) { + processedDict[@"backgroundColor"] = [bgRaw isKindOfClass:[NSNull class]] + ? [NSNull null] + : [RCTConvert UIColor:bgRaw]; + } + + [customStyleClass applyStyleFromDict:processedDict + selectedRange:selectedRange]; + [self anyTextMayHaveBeenModified]; +} + - (void)toggleCheckboxList:(BOOL)checked { CheckboxListStyle *style = (CheckboxListStyle *)stylesDict[@([CheckboxListStyle getType])]; @@ -1855,6 +1925,10 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText { AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])]; NSString *currentAlignment = [alignmentStyle getStyleState]; + CustomStyle *customStyle = stylesDict[@([CustomStyle getType])]; + CustomStyleData *contextCustomStyleData = + [customStyle getCustomStyleDataAt:textView.selectedRange.location]; + emitter->onContextMenuItemPress( {.itemText = [itemText toCppString], .selectedText = [selectedText toCppString], @@ -1881,7 +1955,16 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText { .image = GET_STYLE_STATE([ImageStyle getType]), .mention = GET_STYLE_STATE([MentionStyle getType]), .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]), - .alignment = [currentAlignment UTF8String]}}); + .alignment = [currentAlignment UTF8String], + .customStyle = { + .foregroundColor = + [[contextCustomStyleData.foregroundColor hexString] + UTF8String] + ?: "", + .backgroundColor = + [[contextCustomStyleData.backgroundColor hexString] + UTF8String] + ?: ""}}}); } } @@ -1908,8 +1991,10 @@ - (bool)textView:(UITextView *)textView replacementText:(NSString *)text { // Capture the attributes at range.location that are being replaced // (autocorrect / predictive) so didProcessEditing: can re-stamp them onto the - // replacement. - if (range.length > 0) { + // replacement. Skip pure deletions (text.length == 0) — there is no incoming + // text to receive attributes, and capturing here would cause the deleted + // character's CustomStyleData to be re-stamped onto the widened editedRange. + if (range.length > 0 && text.length > 0) { _capturedAttributesBeforeChange = [textView.textStorage attributesAtIndex:range.location effectiveRange:NULL]; diff --git a/ios/customStyleData/CustomStyleData.h b/ios/customStyleData/CustomStyleData.h new file mode 100644 index 000000000..7ee405f8e --- /dev/null +++ b/ios/customStyleData/CustomStyleData.h @@ -0,0 +1,15 @@ +#pragma once +#import + +@interface CustomStyleData : NSObject + +@property(nonatomic, strong, nullable) UIColor *foregroundColor; +@property(nonatomic, strong, nullable) UIColor *backgroundColor; + +- (BOOL)isEmpty; + +// Applies a partial update from a dict. A key absent from the dict leaves the +// field unchanged; NSNull value clears it. +- (void)mergeFromDict:(NSDictionary *)dict; + +@end diff --git a/ios/customStyleData/CustomStyleData.mm b/ios/customStyleData/CustomStyleData.mm new file mode 100644 index 000000000..c08f1fd54 --- /dev/null +++ b/ios/customStyleData/CustomStyleData.mm @@ -0,0 +1,46 @@ +#import "CustomStyleData.h" + +@implementation CustomStyleData + +- (BOOL)isEmpty { + return _foregroundColor == nil && _backgroundColor == nil; +} + +- (void)mergeFromDict:(NSDictionary *)dict { + id fgVal = dict[@"foregroundColor"]; + if (fgVal != nil) { + self.foregroundColor = + [fgVal isKindOfClass:[UIColor class]] ? (UIColor *)fgVal : nil; + } + id bgVal = dict[@"backgroundColor"]; + if (bgVal != nil) { + self.backgroundColor = + [bgVal isKindOfClass:[UIColor class]] ? (UIColor *)bgVal : nil; + } +} + +- (BOOL)isEqual:(id)object { + if (self == object) + return YES; + if (![object isKindOfClass:[CustomStyleData class]]) + return NO; + CustomStyleData *other = (CustomStyleData *)object; + BOOL fgEqual = (_foregroundColor == other.foregroundColor) || + [_foregroundColor isEqual:other.foregroundColor]; + BOOL bgEqual = (_backgroundColor == other.backgroundColor) || + [_backgroundColor isEqual:other.backgroundColor]; + return fgEqual && bgEqual; +} + +- (NSUInteger)hash { + return [_foregroundColor hash] ^ [_backgroundColor hash]; +} + +- (id)copyWithZone:(NSZone *)zone { + CustomStyleData *copy = [[CustomStyleData allocWithZone:zone] init]; + copy.foregroundColor = self.foregroundColor; + copy.backgroundColor = self.backgroundColor; + return copy; +} + +@end diff --git a/ios/extensions/ColorExtension.h b/ios/extensions/ColorExtension.h index c3a2323c9..33550251b 100644 --- a/ios/extensions/ColorExtension.h +++ b/ios/extensions/ColorExtension.h @@ -5,4 +5,7 @@ - (BOOL)isEqualToColor:(UIColor *)otherColor; - (UIColor *)colorWithResolvedAlpha; - (UIColor *)colorWithResolvedAlpha:(CGFloat)newAlpha; +- (NSString *)hexString; + ++ (UIColor *_Nullable)colorFromCSSString:(NSString *_Nullable)cssString; @end diff --git a/ios/extensions/ColorExtension.mm b/ios/extensions/ColorExtension.mm index 32877787f..8c1f7ff9a 100644 --- a/ios/extensions/ColorExtension.mm +++ b/ios/extensions/ColorExtension.mm @@ -1,5 +1,163 @@ #import "ColorExtension.h" +static NSDictionary *getNamedHexColors(void) { + static NSDictionary *namedColorHexes = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + namedColorHexes = @{ + @"aliceblue" : @"#F0F8FFFF", + @"antiquewhite" : @"#FAEBD7FF", + @"aqua" : @"#00FFFFFF", + @"aquamarine" : @"#7FFFD4FF", + @"azure" : @"#F0FFFFFF", + @"beige" : @"#F5F5DCFF", + @"bisque" : @"#FFE4C4FF", + @"black" : @"#000000FF", + @"blanchedalmond" : @"#FFEBCDFF", + @"blue" : @"#0000FFFF", + @"blueviolet" : @"#8A2BE2FF", + @"brown" : @"#A52A2AFF", + @"burlywood" : @"#DEB887FF", + @"cadetblue" : @"#5F9EA0FF", + @"chartreuse" : @"#7FFF00FF", + @"chocolate" : @"#D2691EFF", + @"coral" : @"#FF7F50FF", + @"cornflowerblue" : @"#6495EDFF", + @"cornsilk" : @"#FFF8DCFF", + @"crimson" : @"#DC143CFF", + @"cyan" : @"#00FFFFFF", + @"darkblue" : @"#00008BFF", + @"darkcyan" : @"#008B8BFF", + @"darkgoldenrod" : @"#B8860BFF", + @"darkgray" : @"#A9A9A9FF", + @"darkgrey" : @"#A9A9A9FF", + @"darkgreen" : @"#006400FF", + @"darkkhaki" : @"#BDB76BFF", + @"darkmagenta" : @"#8B008BFF", + @"darkolivegreen" : @"#556B2FFF", + @"darkorange" : @"#FF8C00FF", + @"darkorchid" : @"#9932CCFF", + @"darkred" : @"#8B0000FF", + @"darksalmon" : @"#E9967AFF", + @"darkseagreen" : @"#8FBC8FFF", + @"darkslateblue" : @"#483D8BFF", + @"darkslategray" : @"#2F4F4FFF", + @"darkslategrey" : @"#2F4F4FFF", + @"darkturquoise" : @"#00CED1FF", + @"darkviolet" : @"#9400D3FF", + @"deeppink" : @"#FF1493FF", + @"deepskyblue" : @"#00BFFFFF", + @"dimgray" : @"#696969FF", + @"dimgrey" : @"#696969FF", + @"dodgerblue" : @"#1E90FFFF", + @"firebrick" : @"#B22222FF", + @"floralwhite" : @"#FFFAF0FF", + @"forestgreen" : @"#228B22FF", + @"fuchsia" : @"#FF00FFFF", + @"gainsboro" : @"#DCDCDCFF", + @"ghostwhite" : @"#F8F8FFFF", + @"gold" : @"#FFD700FF", + @"goldenrod" : @"#DAA520FF", + @"gray" : @"#808080FF", + @"grey" : @"#808080FF", + @"green" : @"#008000FF", + @"greenyellow" : @"#ADFF2FFF", + @"honeydew" : @"#F0FFF0FF", + @"hotpink" : @"#FF69B4FF", + @"indianred" : @"#CD5C5CFF", + @"indigo" : @"#4B0082FF", + @"ivory" : @"#FFFFF0FF", + @"khaki" : @"#F0E68CFF", + @"lavender" : @"#E6E6FAFF", + @"lavenderblush" : @"#FFF0F5FF", + @"lawngreen" : @"#7CFC00FF", + @"lemonchiffon" : @"#FFFACDFF", + @"lightblue" : @"#ADD8E6FF", + @"lightcoral" : @"#F08080FF", + @"lightcyan" : @"#E0FFFFFF", + @"lightgoldenrodyellow" : @"#FAFAD2FF", + @"lightgray" : @"#D3D3D3FF", + @"lightgrey" : @"#D3D3D3FF", + @"lightgreen" : @"#90EE90FF", + @"lightpink" : @"#FFB6C1FF", + @"lightsalmon" : @"#FFA07AFF", + @"lightseagreen" : @"#20B2AAFF", + @"lightskyblue" : @"#87CEFAFF", + @"lightslategray" : @"#778899FF", + @"lightslategrey" : @"#778899FF", + @"lightsteelblue" : @"#B0C4DEFF", + @"lightyellow" : @"#FFFFE0FF", + @"lime" : @"#00FF00FF", + @"limegreen" : @"#32CD32FF", + @"linen" : @"#FAF0E6FF", + @"magenta" : @"#FF00FFFF", + @"maroon" : @"#800000FF", + @"mediumaquamarine" : @"#66CDAAFF", + @"mediumblue" : @"#0000CDFF", + @"mediumorchid" : @"#BA55D3FF", + @"mediumpurple" : @"#9370D8FF", + @"mediumseagreen" : @"#3CB371FF", + @"mediumslateblue" : @"#7B68EEFF", + @"mediumspringgreen" : @"#00FA9AFF", + @"mediumturquoise" : @"#48D1CCFF", + @"mediumvioletred" : @"#C71585FF", + @"midnightblue" : @"#191970FF", + @"mintcream" : @"#F5FFFAFF", + @"mistyrose" : @"#FFE4E1FF", + @"moccasin" : @"#FFE4B5FF", + @"navajowhite" : @"#FFDEADFF", + @"navy" : @"#000080FF", + @"oldlace" : @"#FDF5E6FF", + @"olive" : @"#808000FF", + @"olivedrab" : @"#6B8E23FF", + @"orange" : @"#FFA500FF", + @"orangered" : @"#FF4500FF", + @"orchid" : @"#DA70D6FF", + @"palegoldenrod" : @"#EEE8AAFF", + @"palegreen" : @"#98FB98FF", + @"paleturquoise" : @"#AFEEEEFF", + @"palevioletred" : @"#D87093FF", + @"papayawhip" : @"#FFEFD5FF", + @"peachpuff" : @"#FFDAB9FF", + @"peru" : @"#CD853FFF", + @"pink" : @"#FFC0CBFF", + @"plum" : @"#DDA0DDFF", + @"powderblue" : @"#B0E0E6FF", + @"purple" : @"#800080FF", + @"rebeccapurple" : @"#663399FF", + @"red" : @"#FF0000FF", + @"rosybrown" : @"#BC8F8FFF", + @"royalblue" : @"#4169E1FF", + @"saddlebrown" : @"#8B4513FF", + @"salmon" : @"#FA8072FF", + @"sandybrown" : @"#F4A460FF", + @"seagreen" : @"#2E8B57FF", + @"seashell" : @"#FFF5EEFF", + @"sienna" : @"#A0522DFF", + @"silver" : @"#C0C0C0FF", + @"skyblue" : @"#87CEEBFF", + @"slateblue" : @"#6A5ACDFF", + @"slategray" : @"#708090FF", + @"slategrey" : @"#708090FF", + @"snow" : @"#FFFAFAFF", + @"springgreen" : @"#00FF7FFF", + @"steelblue" : @"#4682B4FF", + @"tan" : @"#D2B48CFF", + @"teal" : @"#008080FF", + @"thistle" : @"#D8BFD8FF", + @"tomato" : @"#FF6347FF", + @"turquoise" : @"#40E0D0FF", + @"violet" : @"#EE82EEFF", + @"wheat" : @"#F5DEB3FF", + @"white" : @"#FFFFFFFF", + @"whitesmoke" : @"#F5F5F5FF", + @"yellow" : @"#FFFF00FF", + @"yellowgreen" : @"#9ACD32FF" + }; + }); + return namedColorHexes; +} + @implementation UIColor (ColorExtension) - (BOOL)isEqualToColor:(UIColor *)otherColor { CGColorSpaceRef colorSpaceRGB = CGColorSpaceCreateDeviceRGB(); @@ -45,4 +203,127 @@ - (UIColor *)colorWithResolvedAlpha:(CGFloat)newAlpha { } return self; } + +// Returns a CSS hex color string. +// Opaque colors produce 6-digit form (#RRGGBB); semi-transparent produce +// 8-digit form (#RRGGBBAA). Returns @"" if the color cannot be expressed +// in RGB. +- (NSString *)hexString { + CGFloat red = 0.0; + CGFloat green = 0.0; + CGFloat blue = 0.0; + CGFloat alpha = 0.0; + + if (![self getRed:&red green:&green blue:&blue alpha:&alpha]) + return @""; + + int r = (int)(red * 255.0 + 0.5); + int g = (int)(green * 255.0 + 0.5); + int b = (int)(blue * 255.0 + 0.5); + int a = (int)(alpha * 255.0 + 0.5); + + if (a == 255) + return [NSString stringWithFormat:@"#%02X%02X%02X", r, g, b]; + return [NSString stringWithFormat:@"#%02X%02X%02X%02X", r, g, b, a]; +} + +// Converts a CSS color string (Hex, RGB, RGBA, or Named) into a UIColor. ++ (UIColor *_Nullable)colorFromCSSString:(NSString *_Nullable)cssString { + if (cssString.length == 0) + return nil; + + // Trim whitespace and force lowercase for easier matching + NSString *str = + [cssString + stringByTrimmingCharactersInSet:[NSCharacterSet + whitespaceAndNewlineCharacterSet]] + .lowercaseString; + + // Handle Hex (#FFF, #FFFFFF, #FFFFFFFF) + if ([str hasPrefix:@"#"]) { + str = [str substringFromIndex:1]; + NSUInteger len = str.length; + + unsigned int value = 0; + NSScanner *scanner = [NSScanner scannerWithString:str]; + if (![scanner scanHexInt:&value]) + return nil; + + CGFloat r, g, b, a = 1.0; + + if (len == 3) { + r = ((value >> 8) & 0xF) / 15.0; + g = ((value >> 4) & 0xF) / 15.0; + b = (value & 0xF) / 15.0; + } else if (len == 6) { + r = ((value >> 16) & 0xFF) / 255.0; + g = ((value >> 8) & 0xFF) / 255.0; + b = (value & 0xFF) / 255.0; + } else if (len == 8) { + r = ((value >> 24) & 0xFF) / 255.0; + g = ((value >> 16) & 0xFF) / 255.0; + b = ((value >> 8) & 0xFF) / 255.0; + a = (value & 0xFF) / 255.0; + } else { + return nil; // Invalid hex length + } + + return [UIColor colorWithRed:r green:g blue:b alpha:a]; + } + + // Handle rgb() and rgba() + if ([str hasPrefix:@"rgb"]) { + NSScanner *scanner = [NSScanner scannerWithString:str]; + + // Scan up to and including the opening parenthesis + [scanner scanUpToString:@"(" intoString:NULL]; + if (![scanner scanString:@"(" intoString:NULL]) + return nil; + + float r = 0, g = 0, b = 0, a = 1.0; + + // Scan Red, then require a comma + if (![scanner scanFloat:&r]) + return nil; + if (![scanner scanString:@"," intoString:NULL]) + return nil; + + // Scan Green, then require a comma + if (![scanner scanFloat:&g]) + return nil; + if (![scanner scanString:@"," intoString:NULL]) + return nil; + + // Scan Blue (comma not required yet, might be alpha or closing parenthesis) + if (![scanner scanFloat:&b]) + return nil; + + // Check if there is a 4th parameter (Alpha) + if ([scanner scanString:@"," intoString:NULL]) { + if (![scanner scanFloat:&a]) + return nil; + } + + // Require the closing parenthesis to guarantee the string wasn't malformed + // or cut off + if (![scanner scanString:@")" intoString:NULL]) + return nil; + + return [UIColor colorWithRed:r / 255.0 + green:g / 255.0 + blue:b / 255.0 + alpha:a]; + } + + // Handle Named Colors + NSString *hexForName = getNamedHexColors()[str]; + if (hexForName) { + // We found a match! Pass the 8-digit hex string right back into this very + // method to reuse the Hex parsing logic. + return [self colorFromCSSString:hexForName]; + } + + return nil; +} + @end diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index a6e84dd6e..b7e06caa2 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1,6 +1,9 @@ #import "HtmlParser.h" #import "AlignmentEntry.h" #import "AlignmentUtils.h" +#import "ColorExtension.h" +#import "CustomStyleData.h" +#include "GumboParser.hpp" #import "ImageData.h" #import "LinkData.h" #import "MentionParams.h" @@ -8,8 +11,6 @@ #import "StyleHeaders.h" #import "StylePair.h" -#include "GumboParser.hpp" - @implementation HtmlParser + (BOOL)isBlockTag:(NSString *)tagName { @@ -41,9 +42,10 @@ + (BOOL)isBlockTag:(NSString *)tagName { * you MUST add it to the `textTags` set below. */ + (NSString *)stripExtraWhiteSpacesAndNewlines:(NSString *)html { - NSSet *textTags = [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4", - @"h5", @"h6", @"li", @"b", @"a", @"s", - @"mention", @"code", @"u", @"i", nil]; + NSSet *textTags = + [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6", + @"li", @"b", @"a", @"s", @"mention", @"code", @"u", + @"i", @"span", nil]; NSMutableString *output = [NSMutableString stringWithCapacity:html.length]; NSMutableString *currentTagBuffer = [NSMutableString string]; @@ -817,9 +819,15 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml { [styleArr addObject:@([BlockQuoteStyle getType])]; } else if ([tagName isEqualToString:@"codeblock"]) { [styleArr addObject:@([CodeBlockStyle getType])]; + } else if ([tagName isEqualToString:@"span"]) { + CustomStyleData *data = [self parseCustomStyleDataFromSpanParams:params]; + if (data == nil || data.isEmpty) { + continue; + } + [styleArr addObject:@([CustomStyle getType])]; + stylePair.styleValue = data; } else { - // some other external tags like span just don't get put into the - // processed styles + // some other external tags don't get put into the processed styles continue; } @@ -849,6 +857,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range BOOL inCodeBlock = NO; BOOL inCheckboxList = NO; unichar lastCharacter = 0; + CustomStyleData *lastCustomStyleData = nil; for (int i = 0; i < text.length; i++) { NSRange currentRange = NSMakeRange(offset + i, 1); @@ -988,6 +997,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range // clear the previous styles previousActiveStyles = [[NSSet alloc] init]; + lastCustomStyleData = nil; // next character opens new paragraph newLine = YES; @@ -1149,6 +1159,23 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range } } + // Force close+reopen if CustomStyle is continuously active but its data + // changed, so adjacent runs with different styles produce separate + // tags instead of being merged into one. + NSNumber *customType = @([CustomStyle getType]); + if (![endedStyles member:customType] && + [currentActiveStyles member:customType] && + [previousActiveStyles member:customType]) { + CustomStyle *customStyleObj = + (CustomStyle *)host.stylesDict[customType]; + CustomStyleData *currentData = + [customStyleObj getStoredCustomStyleDataAt:currentRange.location]; + if (![currentData isEqual:lastCustomStyleData]) { + [fixedEndedStyles addObject:customType]; + [stylesToBeReAdded addObject:customType]; + } + } + // they are sorted in a descending order NSArray *sortedEndedStyles = [fixedEndedStyles sortedArrayUsingDescriptors:@[ [NSSortDescriptor @@ -1194,6 +1221,11 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range // append the letter and escape it if needed [result appendString:[NSString stringByEscapingHtml:currentCharacterStr]]; + // track CustomStyleData for the next character's data-change check + lastCustomStyleData = + [(CustomStyle *)host.stylesDict[@([CustomStyle getType])] + getStoredCustomStyleDataAt:currentRange.location]; + // save current styles for next character's checks previousActiveStyles = currentActiveStyles; } @@ -1413,6 +1445,34 @@ + (NSString *)tagContentForStyle:(NSNumber *)style [style isEqualToNumber:@([CodeBlockStyle getType])]) { // blockquotes and codeblock use

tags the same way lists use

  • return [NSString stringWithFormat:@"p%@", cssStyleString]; + } else if ([style isEqualToNumber:@([CustomStyle getType])]) { + if (openingTag) { + CustomStyle *customStyle = + (CustomStyle *)host.stylesDict[@([CustomStyle getType])]; + if (customStyle != nil) { + CustomStyleData *data = + [customStyle getStoredCustomStyleDataAt:location]; + if (data != nil && !data.isEmpty) { + NSMutableString *cssProps = [NSMutableString string]; + NSString *fg = [[data foregroundColor] hexString]; + NSString *bg = [[data backgroundColor] hexString]; + if (fg.length > 0) { + [cssProps appendFormat:@"color: %@;", fg]; + } + if (bg.length > 0) { + if (cssProps.length > 0) + [cssProps appendString:@" "]; + [cssProps appendFormat:@"background-color: %@;", bg]; + } + if (cssProps.length > 0) { + return [NSString stringWithFormat:@"span style=\"%@\"", cssProps]; + } + } + } + return @"span"; + } else { + return @"span"; + } } return @""; } @@ -1437,6 +1497,62 @@ + (NSString *)prepareCssStyleString:(NSInteger)location return @""; } ++ (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: + (NSString *)params { + static NSRegularExpression *styleAttrRegex; + static NSRegularExpression *fgRegex; + static NSRegularExpression *bgRegex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + styleAttrRegex = [NSRegularExpression + regularExpressionWithPattern:@"style\\s*=\\s*[\"']([^\"']*)[\"']" + options:NSRegularExpressionCaseInsensitive + error:nil]; + // Captures everything after "color:" until a semicolon or end of string + fgRegex = [NSRegularExpression + regularExpressionWithPattern:@"(?:^|;)\\s*color\\s*:\\s*([^;]+)" + options:NSRegularExpressionCaseInsensitive + error:nil]; + + // Captures everything after "background-color:" until a semicolon or end of + // string + bgRegex = [NSRegularExpression + regularExpressionWithPattern:@"background-color\\s*:\\s*([^;]+)" + options:NSRegularExpressionCaseInsensitive + error:nil]; + }); + + NSTextCheckingResult *attrMatch = + [styleAttrRegex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + if (!attrMatch) + return nil; + + NSString *css = [params substringWithRange:[attrMatch rangeAtIndex:1]]; + CustomStyleData *data = [[CustomStyleData alloc] init]; + + NSTextCheckingResult *fgMatch = + [fgRegex firstMatchInString:css + options:0 + range:NSMakeRange(0, css.length)]; + if (fgMatch) { + data.foregroundColor = [UIColor + colorFromCSSString:[css substringWithRange:[fgMatch rangeAtIndex:1]]]; + } + + NSTextCheckingResult *bgMatch = + [bgRegex firstMatchInString:css + options:0 + range:NSMakeRange(0, css.length)]; + if (bgMatch) { + data.backgroundColor = [UIColor + colorFromCSSString:[css substringWithRange:[bgMatch rangeAtIndex:1]]]; + } + + return data.isEmpty ? nil : data; +} + + (void)checkForAlignments:(NSArray *)tagData plainText:(NSString *)plainText foundAlignments:(NSMutableArray *)foundAlignments diff --git a/ios/inputAttributesManager/InputAttributesManager.mm b/ios/inputAttributesManager/InputAttributesManager.mm index fc07d871f..02b953eae 100644 --- a/ios/inputAttributesManager/InputAttributesManager.mm +++ b/ios/inputAttributesManager/InputAttributesManager.mm @@ -95,17 +95,20 @@ - (void)handleDirtyRangesStyling { [ZeroWidthSpaceUtils applyKernForZeroWidthSpacesInRange:dirtyRange host:_input]; - // Sort style types so paragraph styles come first. Their broad visual - // attributes (e.g. foreground color, font) are laid down before inline - // styles override them on their specific sub-ranges. + // Sort style types by priority (0=paragraph, 1=custom, 2=inline) so + // paragraph styles come first. Their broad visual attributes (e.g. + // foreground color, font) are laid down before custom and inline styles + // override them on their specific sub-ranges. NSArray *sortedStyleTypes = [presentStyles.allKeys sortedArrayUsingComparator:^NSComparisonResult(NSNumber *a, NSNumber *b) { - BOOL aPara = [_input->stylesDict[a] isParagraph]; - BOOL bPara = [_input->stylesDict[b] isParagraph]; - if (aPara == bPara) - return NSOrderedSame; - return aPara ? NSOrderedAscending : NSOrderedDescending; + NSInteger aPriority = [_input->stylesDict[a] stylePriority]; + NSInteger bPriority = [_input->stylesDict[b] stylePriority]; + if (aPriority < bPriority) + return NSOrderedAscending; + if (aPriority > bPriority) + return NSOrderedDescending; + return NSOrderedSame; }]; // re-apply meta-attributes and apply visual styling following the saved diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index bc9e5875b..f66e8e558 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -179,6 +179,12 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles } } } + } else if ([styleType isEqualToNumber:@([CustomStyle getType])]) { + CustomStyle *customStyle = (CustomStyle *)baseStyle; + [customStyle setCustomStyleData:stylePair.styleValue + range:styleRange + withTyping:shouldAddTypingAttr + withDirtyRange:YES]; } else { [baseStyle add:styleRange withTyping:shouldAddTypingAttr diff --git a/ios/interfaces/EnrichedTextStyleHeaders.h b/ios/interfaces/EnrichedTextStyleHeaders.h index 008f0fce4..6d1a73af8 100644 --- a/ios/interfaces/EnrichedTextStyleHeaders.h +++ b/ios/interfaces/EnrichedTextStyleHeaders.h @@ -34,6 +34,9 @@ @interface EnrichedTextH6Style : H6Style @end +@interface EnrichedTextCustomStyle : CustomStyle +@end + @interface EnrichedTextBlockQuoteStyle : BlockQuoteStyle @end diff --git a/ios/interfaces/StyleBase.h b/ios/interfaces/StyleBase.h index 41a3d27de..5075c5513 100644 --- a/ios/interfaces/StyleBase.h +++ b/ios/interfaces/StyleBase.h @@ -12,6 +12,7 @@ - (NSString *)getValue; - (NSString *)getMarkerPrefix; - (BOOL)isParagraph; +- (NSInteger)stylePriority; - (BOOL)needsZWS; - (BOOL)appliesStylingToTyping; - (instancetype)initWithHost:(id)host; diff --git a/ios/interfaces/StyleBase.mm b/ios/interfaces/StyleBase.mm index f43c53224..1c0fac945 100644 --- a/ios/interfaces/StyleBase.mm +++ b/ios/interfaces/StyleBase.mm @@ -37,6 +37,13 @@ - (BOOL)isParagraph { return false; } +// Returns the application priority for this style. +// 0 = paragraph, 1 = custom, 2 = inline (default). +// Styles are applied in ascending priority order so inline styles win. +- (NSInteger)stylePriority { + return [self isParagraph] ? 0 : 2; +} + - (BOOL)needsZWS { return NO; } diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index 1a16d3819..510448f2c 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -1,9 +1,20 @@ #pragma once +#import "CustomStyleData.h" #import "ImageData.h" #import "LinkData.h" #import "MentionParams.h" #import "StyleBase.h" +@interface CustomStyle : StyleBase +- (void)applyStyleFromDict:(NSDictionary *)dict selectedRange:(NSRange)range; +- (void)setCustomStyleData:(CustomStyleData *)data + range:(NSRange)range + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange; +- (CustomStyleData *_Nullable)getCustomStyleDataAt:(NSUInteger)location; +- (CustomStyleData *_Nullable)getStoredCustomStyleDataAt:(NSUInteger)location; +@end + @interface BoldStyle : StyleBase @end diff --git a/ios/interfaces/StyleTypeEnum.h b/ios/interfaces/StyleTypeEnum.h index 701d79eaf..a5c163fc8 100644 --- a/ios/interfaces/StyleTypeEnum.h +++ b/ios/interfaces/StyleTypeEnum.h @@ -15,6 +15,7 @@ typedef NS_ENUM(NSInteger, StyleType) { H4, H5, H6, + Custom, Link, Mention, Image, diff --git a/ios/styles/CustomStyle.mm b/ios/styles/CustomStyle.mm new file mode 100644 index 000000000..45def651a --- /dev/null +++ b/ios/styles/CustomStyle.mm @@ -0,0 +1,209 @@ +#import "CustomStyleData.h" +#import "EnrichedTextInputView.h" +#import "RangeUtils.h" +#import "StyleHeaders.h" + +static NSString *const CustomStyleAttributeName = @"EnrichedCustomStyle"; + +@implementation CustomStyle + ++ (StyleType)getType { + return Custom; +} + +- (NSString *)getKey { + return CustomStyleAttributeName; +} + +- (BOOL)isParagraph { + return NO; +} + +- (NSInteger)stylePriority { + return 1; +} + +- (BOOL)styleCondition:(id)value range:(NSRange)range { + if (![value isKindOfClass:[CustomStyleData class]]) + return NO; + return ![(CustomStyleData *)value isEmpty]; +} + +- (void)applyStyling:(NSRange)range { + if (range.length == 0) + return; + + NSUInteger storageLength = self.host.textView.textStorage.length; + if (storageLength == 0) + return; + + NSRange safeRange = NSMakeRange( + range.location, MIN(range.length, storageLength - range.location)); + + // Enumerate each sub-range that carries its own CustomStyleData so that + // characters with different data values each get the correct visual attrs. + [self.host.textView.textStorage + enumerateAttribute:CustomStyleAttributeName + inRange:safeRange + options:0 + usingBlock:^(id value, NSRange subRange, BOOL *stop) { + if (![value isKindOfClass:[CustomStyleData class]]) + return; + CustomStyleData *data = (CustomStyleData *)value; + if (data.isEmpty) + return; + + NSMutableDictionary *attrs = [NSMutableDictionary dictionary]; + if (data.foregroundColor != nil) { + attrs[NSForegroundColorAttributeName] = data.foregroundColor; + attrs[NSUnderlineColorAttributeName] = data.foregroundColor; + attrs[NSStrikethroughColorAttributeName] = + data.foregroundColor; + } + if (data.backgroundColor != nil) { + attrs[NSBackgroundColorAttributeName] = data.backgroundColor; + } + if (attrs.count == 0) + return; + + // Skip newline characters so background color doesn't bleed. + NSArray *nonNewlineRanges = + [RangeUtils getNonNewlineRangesIn:self.host.textView + range:subRange]; + for (NSValue *rangeVal in nonNewlineRanges) { + [self.host.textView.textStorage + addAttributes:attrs + range:[rangeVal rangeValue]]; + } + }]; +} + +- (void)reapplyFromStylePair:(StylePair *)pair { + NSRange range = [pair.rangeValue rangeValue]; + CustomStyleData *data = (CustomStyleData *)pair.styleValue; + if (data == nil || data.isEmpty) + return; + [self.host.textView.textStorage addAttribute:CustomStyleAttributeName + value:data + range:range]; +} + +- (AttributeEntry *)getEntryIfPresent:(NSRange)range { + CustomStyleData *data = [self getCustomStyleDataAt:range.location]; + if (data == nil || data.isEmpty) + return nullptr; + + AttributeEntry *entry = [[AttributeEntry alloc] init]; + entry.key = CustomStyleAttributeName; + entry.value = data; + return entry; +} + +// MARK: - Public non-standard methods + +- (void)setCustomStyleData:(CustomStyleData *)data + range:(NSRange)range + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange { + if (range.length > 0) { + if (data == nil || data.isEmpty) { + [self remove:range withDirtyRange:withDirtyRange]; + return; + } + [self.host.textView.textStorage addAttribute:CustomStyleAttributeName + value:data + range:range]; + if (withDirtyRange) { + [self.host.attributesManager addDirtyRange:range]; + } + } + + if (withTyping) { + if (data == nil || data.isEmpty) { + [self removeTyping]; + } else { + NSMutableDictionary *newTypingAttrs = + [self.host.textView.typingAttributes mutableCopy]; + newTypingAttrs[CustomStyleAttributeName] = data; + self.host.textView.typingAttributes = newTypingAttrs; + } + } +} + +- (CustomStyleData *_Nullable)getCustomStyleDataAt:(NSUInteger)location { + NSRange selectedRange = self.host.textView.selectedRange; + if (self.host.textView.isEditable && selectedRange.length == 0 && + selectedRange.location == location) { + id typingValue = + self.host.textView.typingAttributes[CustomStyleAttributeName]; + if ([typingValue isKindOfClass:[CustomStyleData class]]) + return (CustomStyleData *)typingValue; + return nil; + } + + return [self getStoredCustomStyleDataAt:location]; +} + +// Reads CustomStyleData directly from textStorage, bypassing typingAttributes. +- (CustomStyleData *_Nullable)getStoredCustomStyleDataAt:(NSUInteger)location { + NSUInteger length = self.host.textView.textStorage.length; + if (length == 0) + return nil; + NSUInteger searchLocation = (location >= length) ? length - 1 : location; + id value = [self.host.textView.textStorage attribute:CustomStyleAttributeName + atIndex:searchLocation + longestEffectiveRange:nil + inRange:NSMakeRange(0, length)]; + if (![value isKindOfClass:[CustomStyleData class]]) + return nil; + return (CustomStyleData *)value; +} + +- (void)applyStyleFromDict:(NSDictionary *)dict selectedRange:(NSRange)range { + BOOL withTyping = range.length == 0; + + if (!withTyping) { + // Enumerate each existing sub-range and merge the partial update into its + // own data so per-character differences (e.g. fg color on some chars) are + // preserved when only one field (e.g. bg color) is being changed. + NSUInteger storageLength = self.host.textView.textStorage.length; + if (storageLength == 0) + return; + + NSRange safeRange = NSMakeRange( + range.location, MIN(range.length, storageLength - range.location)); + + [self.host.textView.textStorage + enumerateAttribute:CustomStyleAttributeName + inRange:safeRange + options:0 + usingBlock:^(id value, NSRange subRange, BOOL *stop) { + CustomStyleData *existing = + [value isKindOfClass:[CustomStyleData class]] + ? (CustomStyleData *)value + : nil; + CustomStyleData *merged = + existing != nil ? [existing copy] + : [[CustomStyleData alloc] init]; + [merged mergeFromDict:dict]; + [self setCustomStyleData:merged + range:subRange + withTyping:NO + withDirtyRange:YES]; + }]; + } else { + // Cursor only: merge into current data and update typing attributes. + CustomStyleData *existing = [self getCustomStyleDataAt:range.location]; + CustomStyleData *merged = + existing != nil ? [existing copy] : [[CustomStyleData alloc] init]; + [merged mergeFromDict:dict]; + [self setCustomStyleData:merged + range:range + withTyping:YES + withDirtyRange:NO]; + [self.host.attributesManager + didRemoveTypingAttribute:CustomStyleAttributeName]; + } +} + +@end diff --git a/ios/styles/EnrichedTextStyles.mm b/ios/styles/EnrichedTextStyles.mm index fde9abfac..f125f66dc 100644 --- a/ios/styles/EnrichedTextStyles.mm +++ b/ios/styles/EnrichedTextStyles.mm @@ -33,6 +33,9 @@ @implementation EnrichedTextH5Style @implementation EnrichedTextH6Style @end +@implementation EnrichedTextCustomStyle +@end + @implementation EnrichedTextBlockQuoteStyle @end diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm index 1f930a015..bd1ea3728 100644 --- a/ios/textHtmlParser/TextHtmlParser.mm +++ b/ios/textHtmlParser/TextHtmlParser.mm @@ -122,6 +122,12 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { } } } + } else if ([styleType isEqualToNumber:@([CustomStyle getType])]) { + CustomStyle *customStyle = (CustomStyle *)style; + [customStyle setCustomStyleData:stylePair.styleValue + range:styleRange + withTyping:NO + withDirtyRange:NO]; } else { [style add:styleRange withTyping:NO withDirtyRange:NO]; } diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm index 838269221..a3ef1b178 100644 --- a/ios/utils/StyleUtils.mm +++ b/ios/utils/StyleUtils.mm @@ -91,7 +91,8 @@ + (NSDictionary *)conflictMap { @([CheckboxListStyle getType]) ], @([ImageStyle getType]) : - @[ @([LinkStyle getType]), @([MentionStyle getType]) ] + @[ @([LinkStyle getType]), @([MentionStyle getType]) ], + @([CustomStyle getType]) : @[] }; } @@ -123,23 +124,35 @@ + (NSDictionary *)blockingMap { @([AlignmentStyle getType]) : @[], @([BlockQuoteStyle getType]) : @[], @([CodeBlockStyle getType]) : @[], - @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ] + @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ], + @([CustomStyle getType]) : @[] }; } + (NSDictionary *)stylesDictForHost:(id)host isInput:(BOOL)isInput { NSArray *baseClasses = @[ - [BoldStyle class], [ItalicStyle class], - [UnderlineStyle class], [StrikethroughStyle class], - [InlineCodeStyle class], [LinkStyle class], - [MentionStyle class], [H1Style class], - [H2Style class], [H3Style class], - [H4Style class], [H5Style class], - [H6Style class], [UnorderedListStyle class], - [OrderedListStyle class], [CheckboxListStyle class], - [AlignmentStyle class], [BlockQuoteStyle class], - [CodeBlockStyle class], [ImageStyle class] + [BoldStyle class], + [ItalicStyle class], + [UnderlineStyle class], + [StrikethroughStyle class], + [InlineCodeStyle class], + [LinkStyle class], + [MentionStyle class], + [H1Style class], + [H2Style class], + [H3Style class], + [H4Style class], + [H5Style class], + [H6Style class], + [CustomStyle class], + [UnorderedListStyle class], + [OrderedListStyle class], + [CheckboxListStyle class], + [AlignmentStyle class], + [BlockQuoteStyle class], + [CodeBlockStyle class], + [ImageStyle class] ]; NSArray *viewerClasses = @[ @@ -156,6 +169,7 @@ + (NSDictionary *)stylesDictForHost:(id)host [EnrichedTextH4Style class], [EnrichedTextH5Style class], [EnrichedTextH6Style class], + [EnrichedTextCustomStyle class], [EnrichedTextUnorderedListStyle class], [EnrichedTextOrderedListStyle class], [EnrichedTextCheckboxListStyle class], diff --git a/package.json b/package.json index 1145fad9e..0fbf32ef7 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.3", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "playwright": "1.58.2", "prettier": "^3.0.3", "react": "19.1.0", @@ -147,15 +148,35 @@ ], "packageManager": "yarn@4.13.0", "jest": { - "preset": "react-native", - "modulePathIgnorePatterns": [ - "/apps/example/node_modules", - "/lib/" - ], - "testPathIgnorePatterns": [ - "/node_modules/", - "/.playwright/", - "/apps/example-web/" + "projects": [ + { + "displayName": "native", + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/apps/example/node_modules", + "/lib/" + ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/.playwright/", + "/apps/example-web/", + "/src/web/" + ] + }, + { + "displayName": "web", + "testEnvironment": "jsdom", + "testMatch": [ + "/src/web/**/__tests__/**/*.test.{ts,tsx}" + ], + "modulePathIgnorePatterns": [ + "/apps/example/node_modules", + "/lib/" + ], + "transform": { + "^.+\\.(js|ts|tsx)$": "babel-jest" + } + } ] }, "commitlint": { diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index f399633c0..077332ade 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -14,13 +14,15 @@ import EnrichedTextInputNativeComponent, { type OnMentionDetectedInternal, type OnRequestHtmlResultEvent, } from '../spec/EnrichedTextInputNativeComponent'; -import type { - HostInstance, - MeasureInWindowOnSuccessCallback, - MeasureLayoutOnSuccessCallback, - MeasureOnSuccessCallback, - NativeMethods, - NativeSyntheticEvent, +import { + processColor, + type ColorValue, + type HostInstance, + type MeasureInWindowOnSuccessCallback, + type MeasureLayoutOnSuccessCallback, + type MeasureOnSuccessCallback, + type NativeMethods, + type NativeSyntheticEvent, } from 'react-native'; import { normalizeHtmlStyle } from '../utils/normalizeHtmlStyle'; import { toNativeRegexConfig } from '../utils/regexParser'; @@ -39,6 +41,19 @@ const warnMentionIndicators = (indicator: string) => { ); }; +const getSafeColorInt = ( + color: ColorValue | null | undefined +): number | null => { + if (color == null) return null; + + const processed = processColor(color); + if (typeof processed === 'number') { + return processed; + } + + return null; +}; + type ComponentType = (Component & NativeMethods) | null; type HtmlRequest = { @@ -277,6 +292,22 @@ export const EnrichedTextInput = ({ ) => { Commands.setTextAlignment(nullthrows(nativeRef.current), alignment); }, + setStyle: (customStyle: { + foregroundColor?: ColorValue | null; + backgroundColor?: ColorValue | null; + }) => { + const payload: { + foregroundColor?: number | null; + backgroundColor?: number | null; + } = {}; + if (customStyle.foregroundColor !== undefined) { + payload.foregroundColor = getSafeColorInt(customStyle.foregroundColor); + } + if (customStyle.backgroundColor !== undefined) { + payload.backgroundColor = getSafeColorInt(customStyle.backgroundColor); + } + Commands.setStyle(nullthrows(nativeRef.current), JSON.stringify(payload)); + }, })); const handleMentionEvent = (e: NativeSyntheticEvent) => { diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 20e9d12b4..3467acadb 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -124,6 +124,10 @@ export interface OnChangeStateEvent { isBlocking: boolean; }; alignment: string; + customStyle: { + foregroundColor: string; + backgroundColor: string; + }; } export interface OnLinkDetected { @@ -280,6 +284,10 @@ export interface OnContextMenuItemPressEvent { isBlocking: boolean; }; alignment: string; + customStyle: { + foregroundColor: string; + backgroundColor: string; + }; }; } @@ -482,6 +490,10 @@ interface NativeCommands { viewRef: React.ElementRef, alignment: string ) => void; + setStyle: ( + viewRef: React.ElementRef, + styleJSON: string + ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ @@ -516,6 +528,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'addMention', 'requestHTML', 'setTextAlignment', + 'setStyle', ], }); diff --git a/src/types.ts b/src/types.ts index 0509ba648..8d1a8094e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,13 @@ import type { RefObject } from 'react'; -import type { - ColorValue, - DimensionValue, - NativeMethods, - NativeSyntheticEvent, - ReturnKeyTypeOptions, - TargetedEvent, - TextStyle, - ViewProps, +import { + type ColorValue, + type DimensionValue, + type NativeMethods, + type NativeSyntheticEvent, + type ReturnKeyTypeOptions, + type TargetedEvent, + type TextStyle, + type ViewProps, } from 'react-native'; /** @@ -380,6 +380,10 @@ export interface OnChangeStateEvent { isBlocking: boolean; }; alignment: string; + customStyle: { + foregroundColor: string; + backgroundColor: string; + }; } export interface OnLinkDetected { @@ -564,6 +568,10 @@ export interface EnrichedTextInputInstance extends NativeMethods { setTextAlignment: ( alignment: 'left' | 'center' | 'right' | 'justify' | 'auto' ) => void; + setStyle: (customStyle: { + foregroundColor?: ColorValue | null; + backgroundColor?: ColorValue | null; + }) => void; } export interface ContextMenuItem { diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 4ea1742c9..384459cc9 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -35,7 +35,7 @@ import { useOnLinkDetected } from './useOnLinkDetected'; import { prepareHtmlForTiptap, normalizeHtmlFromTiptap, -} from './tiptapHtmlNormalizer'; +} from './normalization/tiptapHtmlNormalizer'; import { ENRICHED_TEXT_INPUT_DEFAULT_PROPS } from '../utils/EnrichedTextInputDefaultProps'; import { enrichedInputStyleToCSSProperties } from './styleConversion/enrichedInputStyleToCSSProperties'; import { enrichedInputThemingToCSSProperties } from './styleConversion/enrichedInputThemingToCSSProperties'; @@ -111,9 +111,12 @@ export const EnrichedTextInput = ({ onChangeMention, onEndMention, htmlStyle, + useHtmlNormalizer, }: EnrichedTextInputProps) => { const tiptapContent = - defaultValue != null ? prepareHtmlForTiptap(defaultValue) : defaultValue; + defaultValue != null + ? prepareHtmlForTiptap(defaultValue, useHtmlNormalizer) + : defaultValue; const resolvedHtmlStyle = useMemo( () => mergeWithDefaultHtmlStyle(htmlStyle), @@ -165,6 +168,11 @@ export const EnrichedTextInput = ({ onKeyPressRef.current = onKeyPress; }, [onKeyPress]); + const useHtmlNormalizerRef = useRef(useHtmlNormalizer); + useEffect(() => { + useHtmlNormalizerRef.current = useHtmlNormalizer; + }, [useHtmlNormalizer]); + const handleKeyDown = (doc: Node, event: KeyboardEvent): boolean => { onKeyPressRef.current?.(adaptWebToNativeEvent(event, { key: event.key })); if (event.key !== 'Enter') { @@ -264,6 +272,9 @@ export const EnrichedTextInput = ({ autoCapitalize, enterkeyhint: returnKeyTypeToEnterKeyHint(returnKeyType), }, + transformPastedHTML: (html) => { + return prepareHtmlForTiptap(html, useHtmlNormalizerRef.current); + }, }, }, [tiptapContent, extensions] @@ -307,7 +318,9 @@ export const EnrichedTextInput = ({ focus: () => editor.commands.focus(), blur: () => editor.commands.blur(), setValue: (value: string) => - editor.commands.setContent(prepareHtmlForTiptap(value)), + editor.commands.setContent( + prepareHtmlForTiptap(value, useHtmlNormalizerRef.current) + ), setSelection: (start, end) => { const doc = editor.state.doc; runFocused(editor, (c) => @@ -355,6 +368,7 @@ export const EnrichedTextInput = ({ measureLayout: () => {}, setNativeProps: () => {}, setTextAlignment: () => {}, + setStyle: () => {}, }), [editor] ); diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts new file mode 100644 index 000000000..3802e54f6 --- /dev/null +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -0,0 +1,450 @@ +/* + * Port of cpp/tests/GumboParserTest.cpp. Each describe() mirrors a TEST() group + * from the C++ suite. The expected outputs are the same as the native + * normalizer's outputs + */ +import { normalizeHtml } from '../normalization/htmlNormalizer'; + +describe('htmlNormalizer', () => { + describe('TagRemappings', () => { + test.each([ + ['x', 'x'], + ['x', 'x'], + ['x', 'x'], + ['x', 'x'], + ['x', 'x'], + ['
    x
    ', '

    x

    '], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('GoogleDocsWrapper', () => { + test.each([ + ['x', 'x'], + ['', ''], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('TagOmissions', () => { + test.each([ + ["", ''], + ['', ''], + ['', ''], + ['', ''], + ["", ''], + ['', ''], + ['', ''], + ['', ''], + // Nested tags + ['', ''], + ['

    x

    ', '

    x

    '], + ['

    x

    ', '

    x

    '], + ['

    x

    ', '

    x

    '], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('TableOmissions', () => { + test.each([ + ['
    ', ''], + ['', ''], + ['', ''], + ['', ''], + ['', ''], + ['', ''], + ['', ''], + ['', ''], + ['', ''], + ['', ''], + [ + '
    EmilTobiasLinus
    ', + '

    Emil Tobias Linus

    ', + ], + [ + '
    EmilTobiasLinus
    161410
    ', + '

    Emil Tobias Linus

    16 14 10

    ', + ], + [ + '
    Person 1Person 2Person 3
    EmilTobiasLinus
    161410
    ', + '

    Person 1 Person 2 Person 3

    Emil Tobias Linus

    16 14 10

    ', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('SpanRemappings', () => { + test.each([ + // Bold + ['x', 'x'], + ['x', 'x'], + ["x", 'x'], + + // Italic + ['x', 'x'], + ['x', 'x'], + ["x", 'x'], + + // Underline + ['x', 'x'], + ['x', 'x'], + ["x", 'x'], + + // Strikethrough + ['x', 'x'], + ['x', 'x'], + ["x", 'x'], + + // Bold + Italic (either order) + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + + // Bold + Underline (either order) + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + + // Bold + Strikethrough (either order) + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + + // Underline + Strikethrough (either order) + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + + // Three-way combinations + [ + 'x', + 'x', + ], + [ + 'x', + 'x', + ], + [ + "x", + 'x', + ], + [ + "x", + 'x', + ], + [ + "x", + 'x', + ], + [ + "x", + 'x', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('EnrichedTagRemappings', () => { + test.each([ + // Block elements + ['x', '

    x

    '], + ['

    x

    ', '

    x

    '], + ['
    x
    ', '

    x

    '], + [ + '

    x

    ', + '

    x

    ', + ], + + // Headings + ['

    x

    ', '

    x

    '], + ['

    x

    ', '

    x

    '], + ['

    x

    ', '

    x

    '], + ['

    x

    ', '

    x

    '], + ['
    x
    ', '
    x
    '], + ['
    x
    ', '
    x
    '], + + // Self-closing tags + ['
    ', '
    '], + [ + '', + '', + ], + [ + "", + '', + ], + + // Lists + ['
    • x
    ', '
    • x
    '], + ['
    1. x
    ', '
    1. x
    '], + + // Checkbox lists + [ + "
    • x
    ", + '
    • x
    ', + ], + [ + '
    • x
    ', + '
    • x
    ', + ], + [ + "
    • x
    ", + '
    • x
    ', + ], + [ + '
    • x
    ', + '
    • x
    ', + ], + + // Mentions (note: cpp reorders attrs to id, text, indicator) + [ + "@John Doe", + '@John Doe', + ], + [ + '@John Doe', + '@John Doe', + ], + + // Link + [ + 'Google', + 'Google', + ], + [ + "Google", + 'Google', + ], + + // Inline + ['x', 'x'], + ['x', 'x'], + ['x', 'x'], + ['x', 'x'], + ['x', 'x'], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('DivRemappings', () => { + test.each([ + ['
    x
    ', '

    x

    '], + ['

    x

    ', '

    x

    '], + ['

    x

    y

    ', '

    x

    y

    '], + ['
    x
    ', '

    x

    '], + [ + '
    x
    y
    ', + '

    x

    y

    ', + ], + + // Without whitespace + [ + '--
    John Doe
    Software Engineer
    ', + '

    --

    John Doe

    Software Engineer

    ', + ], + [ + '
    John Doe
    Software Engineer
    ', + '

    John Doe

    Software Engineer

    ', + ], + [ + "--
    John Doe
    Software Engineer
    ", + '

    --

    John Doe

    Software Engineer

    ', + ], + [ + "--
    John Doe
    Software Engineer
    ", + '

    --

    John Doe

    Software Engineer

    ', + ], + [ + "--
    John Doe
    Software Engineer
    ", + '

    --

    John Doe

    Software Engineer

    ', + ], + [ + "--
    John Doe
    Software Engineer
    ", + '

    --

    John Doe

    Software Engineer

    ', + ], + + [ + '

    here's more!

    image.png', + '

    here's more!


    image.png

    ', + ], + [ + '
    what do you think of this craziness
    • another one hello

      hi
    ', + '

    what do you think of this craziness

    another one hello

    hi

    ', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('ListFlattening', () => { + test.each([ + [ + '
      1. x
      2. y
    ', + '
    • x
    • y
    ', + ], + [ + '
    • x
      1. y
      2. z
    ', + '
    • x
    • y
    • z
    ', + ], + [ + '
      1. x
      2. y
    • z
    ', + '
    • x
    • y
    • z
    ', + ], + [ + '
    1. x
      • y
      • z
    ', + '
    1. x
    2. y
    3. z
    ', + ], + [ + '
      • x
      • y
    1. z
    ', + '
    1. x
    2. y
    3. z
    ', + ], + [ + "
      • x
      • y
    1. z
    ", + '
    1. x
    2. y
    3. z
    ', + ], + [ + "
      1. x
      2. y
    • z
    ", + '
    • x
    • y
    • z
    ', + ], + [ + '
    • x
      1. y
        • z
    ', + '
    • x
    • y
    • z
    ', + ], + [ + "
    • x
      1. y
        • z
    ", + '
    • x
    • y
    • z
    ', + ], + [ + "
    • x
      1. y
        • z
    ", + '
    • x
    • y
    • z
    ', + ], + [ + '
    • another one hi kacper,

      hi
    ', + '
    • another one hi kacper,
    • hi
    ', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + + describe('BrRemappings', () => { + test('inline collapses around
    stay flat', () => { + expect( + normalizeHtml( + "

    Asdasdasd



    Sent with Net

    " + ) + ).toBe( + '

    Asdasdasd



    Sent with Net

    ' + ); + }); + }); + + describe('character escaping', () => { + // Each special character in text content is re-emitted as its entity. + test.each([ + ['

    a & b

    ', '

    a & b

    '], + ['

    a < b

    ', '

    a < b

    '], + ['

    a > b

    ', '

    a > b

    '], + ['

    "quoted"

    ', '

    "quoted"

    '], + ["

    it's

    ", '

    it's

    '], + ['

    &<>"\'

    ', '

    &<>"'

    '], + ['

    &<>"\'

    ', '

    &<>"'

    '], + [ + 'x', + 'x', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); +}); diff --git a/src/web/checkboxHtmlNormalizer.ts b/src/web/normalization/checkboxHtmlNormalizer.ts similarity index 100% rename from src/web/checkboxHtmlNormalizer.ts rename to src/web/normalization/checkboxHtmlNormalizer.ts diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts new file mode 100644 index 000000000..be34e44f9 --- /dev/null +++ b/src/web/normalization/htmlNormalizer.ts @@ -0,0 +1,609 @@ +/* + * Custom HTML normalizer for TipTap input. + * Mirrors the native GumboNormalizer (cpp/parser/GumboNormalizer.c) + */ + +type CssStyles = { + bold: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; +}; + +const INLINE_TAGS = new Set([ + 'b', + 'i', + 'u', + 's', + 'code', + 'a', + 'strong', + 'em', + 'del', + 'strike', + 'ins', + 'mention', +]); + +const BLOCK_TAGS = new Set([ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'blockquote', + 'codeblock', + 'pre', +]); + +const SELF_CLOSING_TAGS = new Set(['br', 'img']); + +const PASS_TAGS = new Set(['html', 'head', 'body']); + +const STRIPPED_TAGS = new Set(['meta', 'style', 'script', 'title', 'link']); + +const TABLE_TAGS = new Set([ + 'table', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'td', + 'th', + 'caption', + 'colgroup', + 'col', +]); + +function canonicalName(name: string): string { + switch (name) { + case 'strong': + return 'b'; + case 'em': + return 'i'; + case 'del': + case 'strike': + return 's'; + case 'ins': + return 'u'; + case 'pre': + return 'codeblock'; + default: + return name; + } +} + +type TagClass = 'skip' | 'inline' | 'block' | 'self-closing' | 'pass'; + +function classifyTag(name: string): TagClass { + if (INLINE_TAGS.has(name)) return 'inline'; + if (BLOCK_TAGS.has(name)) return 'block'; + if (SELF_CLOSING_TAGS.has(name)) return 'self-closing'; + if (PASS_TAGS.has(name)) return 'pass'; + return 'skip'; +} + +function isElement(node: Node | null): node is Element { + return !!node && node.nodeType === Node.ELEMENT_NODE; +} + +function isText(node: Node | null): boolean { + return !!node && node.nodeType === Node.TEXT_NODE; +} + +function tagName(node: Node | null): string | null { + if (!isElement(node)) return null; + return node.tagName.toLowerCase(); +} + +function isListNode(node: Node | null): boolean { + const n = tagName(node); + return n === 'ul' || n === 'ol'; +} + +function isBlockquoteNode(node: Node | null): boolean { + return tagName(node) === 'blockquote'; +} + +function isBrNode(node: Node | null): boolean { + return tagName(node) === 'br'; +} + +function isBlockProducing(node: Node | null): boolean { + const n = tagName(node); + if (!n) return false; + if (classifyTag(n) === 'block') return true; + return n === 'div' || n === 'table' || n === 'tr'; +} + +function isPurelyInline(node: Element): boolean { + for (const child of Array.from(node.childNodes)) { + if (isBlockProducing(child)) return false; + } + return true; +} + +function hasBlockOrBqChild(node: Element): boolean { + for (const child of Array.from(node.childNodes)) { + if (isBlockProducing(child) || isBlockquoteNode(child)) return true; + } + return false; +} + +function findCssValue(style: string, prop: string): string | null { + // Returns the value of the last declaration with this property name. + // Mirrors GumboNormalizer's find_css_value scanning behavior. + const re = new RegExp( + `(?:^|;)\\s*${prop.replace(/[-/\\^$*+?.()|[\\]{}]/g, '\\$&')}\\s*:\\s*([^;]*)`, + 'i' + ); + + const m = re.exec(style); + if (m !== null) { + return (m[1] ?? '').trim(); + } + return null; +} + +function findAllCssValues(style: string, prop: string): string[] { + const re = new RegExp( + `(?:^|;)\\s*${prop.replace(/[-/\\^$*+?.()|[\\]{}]/g, '\\$&')}\\s*:\\s*([^;]*)`, + 'gi' + ); + const out: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(style)) !== null) { + out.push((m[1] ?? '').trim()); + } + return out; +} + +function parseCssStyle(style: string | null): CssStyles { + const result: CssStyles = { + bold: false, + italic: false, + underline: false, + strikethrough: false, + }; + if (!style) return result; + + const fw = findCssValue(style, 'font-weight'); + if (fw) { + const lower = fw.toLowerCase(); + if (lower.includes('bold') || lower.includes('bolder')) { + result.bold = true; + } else { + const num = parseInt(fw, 10); + if (!Number.isNaN(num) && num >= 700) result.bold = true; + } + } + + const fs = findCssValue(style, 'font-style'); + if (fs) { + const lower = fs.toLowerCase(); + if (lower.includes('italic') || lower.includes('oblique')) + result.italic = true; + } + + // text-decoration / text-decoration-line: scan ALL declarations + const decorations = [ + ...findAllCssValues(style, 'text-decoration-line'), + ...findAllCssValues(style, 'text-decoration'), + ]; + for (const v of decorations) { + const lower = v.toLowerCase(); + if (lower.includes('underline')) result.underline = true; + if (lower.includes('line-through')) result.strikethrough = true; + } + + return result; +} + +function extraStyles(s: CssStyles, tag: string): CssStyles { + return { + bold: s.bold && tag !== 'b', + italic: s.italic && tag !== 'i', + underline: s.underline && tag !== 'u', + strikethrough: s.strikethrough && tag !== 's', + }; +} + +function emitStylesOpen(s: CssStyles): string { + let out = ''; + if (s.bold) out += ''; + if (s.italic) out += ''; + if (s.underline) out += ''; + if (s.strikethrough) out += ''; + return out; +} + +function emitStylesClose(s: CssStyles): string { + let out = ''; + if (s.strikethrough) out += ''; + if (s.underline) out += ''; + if (s.italic) out += ''; + if (s.bold) out += ''; + return out; +} + +function emitOneAttr(el: Element, attr: string): string { + const val = el.getAttribute(attr); + if (val == null || val === '') return ''; + const escaped = escapeText(val); + return ` ${attr}="${escaped}"`; +} + +function emitAttributes(el: Element, name: string): string { + switch (name) { + case 'a': + return emitOneAttr(el, 'href'); + case 'img': + return ( + emitOneAttr(el, 'src') + + emitOneAttr(el, 'alt') + + emitOneAttr(el, 'width') + + emitOneAttr(el, 'height') + ); + case 'ul': { + const val = el.getAttribute('data-type'); + return val === 'checkbox' ? ' data-type="checkbox"' : ''; + } + case 'li': + return el.hasAttribute('checked') ? ' checked' : ''; + case 'mention': + return ( + emitOneAttr(el, 'id') + + emitOneAttr(el, 'text') + + emitOneAttr(el, 'indicator') + ); + default: + return ''; + } +} + +function isGoogleDocsWrapper(el: Element, tag: string): boolean { + if (tag !== 'b') return false; + const id = el.getAttribute('id'); + return !!id && id.startsWith('docs-internal-guid-') && id.length > 20; +} + +function escapeText(s: string): string { + return s.replace(/[&<>"']/g, (match) => { + switch (match) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return match; + } + }); +} + +// --- Blockquote content flattening --- + +function flushInlineP(ib: { buf: string }, out: { buf: string }): void { + if (ib.buf.length > 0) { + out.buf += `

    ${ib.buf}

    `; + ib.buf = ''; + } +} + +function flattenBqChildren( + node: Element, + ib: { buf: string }, + out: { buf: string } +): void { + for (const child of Array.from(node.childNodes)) { + flattenBqNode(child, ib, out); + } +} + +function flattenBqNode( + node: Node, + ib: { buf: string }, + out: { buf: string } +): void { + if (isText(node)) { + const t = node.textContent ?? ''; + ib.buf += escapeText(t); + return; + } + if (!isElement(node)) return; + if (isBrNode(node)) { + flushInlineP(ib, out); + return; + } + if (isBlockProducing(node) || isBlockquoteNode(node)) { + flushInlineP(ib, out); + flattenBqChildren(node, ib, out); + flushInlineP(ib, out); + return; + } + walkNode(node, ib); +} + +// --- List item content flattening --- + +type LiCtx = { + el: Element; + styles: CssStyles; + nestedLists: Element[]; +}; + +function flushLiBuffer( + ib: { buf: string }, + out: { buf: string }, + ctx: LiCtx +): void { + if (ib.buf.length === 0) return; + out.buf += ``; + out.buf += emitStylesOpen(ctx.styles); + out.buf += ib.buf; + out.buf += emitStylesClose(ctx.styles); + out.buf += '
  • '; + ib.buf = ''; +} + +function flattenLiChildren( + node: Element, + ib: { buf: string }, + out: { buf: string }, + ctx: LiCtx +): void { + for (const child of Array.from(node.childNodes)) { + flattenLiNode(child, ib, out, ctx); + } +} + +function flattenLiNode( + node: Node, + ib: { buf: string }, + out: { buf: string }, + ctx: LiCtx +): void { + if (isText(node)) { + ib.buf += escapeText(node.textContent ?? ''); + return; + } + if (!isElement(node)) return; + if (isListNode(node)) { + ctx.nestedLists.push(node); + return; + } + if (isBrNode(node)) { + flushLiBuffer(ib, out, ctx); + return; + } + if (isBlockProducing(node) || isBlockquoteNode(node)) { + flushLiBuffer(ib, out, ctx); + flattenLiChildren(node, ib, out, ctx); + flushLiBuffer(ib, out, ctx); + return; + } + walkNode(node, ib); +} + +// --- Main walker --- + +function walkChildren(node: Element, out: { buf: string }): void { + const children = Array.from(node.childNodes); + const parentIsList = isListNode(node); + + let hasBlock = false; + for (const c of children) { + if (isBlockProducing(c)) { + hasBlock = true; + break; + } + } + + let i = 0; + while (i < children.length) { + const child = children[i]!; + + // Flatten list-inside-list + if (parentIsList && isElement(child) && isListNode(child)) { + walkChildren(child, out); + i++; + continue; + } + + // Merge consecutive blockquotes + if (isElement(child) && isBlockquoteNode(child)) { + out.buf += '
    '; + const bqIb = { buf: '' }; + while (i < children.length) { + const cur = children[i]; + if (!cur || !isElement(cur) || !isBlockquoteNode(cur)) break; + flattenBqChildren(cur, bqIb, out); + i++; + } + flushInlineP(bqIb, out); + out.buf += '
    '; + continue; + } + + // Auto-paragraph: group inline runs into

    when mixed with blocks + if ( + hasBlock && + !parentIsList && + !isBlockProducing(child) && + !(isElement(child) && isBlockquoteNode(child)) + ) { + const ib = { buf: '' }; + while (i < children.length) { + const cur = children[i]!; + if ( + isBlockProducing(cur) || + (isElement(cur) && isBlockquoteNode(cur)) + ) { + break; + } + if (isElement(cur) && isBrNode(cur)) { + if (ib.buf.length > 0) { + flushInlineP(ib, out); + } else { + out.buf += '
    '; + } + i++; + continue; + } + // Transparent inline wrapper for block/bq children + if (isElement(cur) && hasBlockOrBqChild(cur)) { + flushInlineP(ib, out); + walkChildren(cur, out); + i++; + continue; + } + walkNode(cur, ib); + i++; + } + flushInlineP(ib, out); + continue; + } + + walkNode(child, out); + i++; + } +} + +function walkNode(node: Node, out: { buf: string }): void { + if (isText(node)) { + out.buf += escapeText(node.textContent ?? ''); + return; + } + if (!isElement(node)) return; + + const name = node.tagName.toLowerCase(); + + if (STRIPPED_TAGS.has(name)) return; + + if (isGoogleDocsWrapper(node, name)) { + walkChildren(node, out); + return; + } + + const outName = canonicalName(name); + const cls = classifyTag(name); + + // : CSS style → inline tags + if (name === 'span') { + const s = parseCssStyle(node.getAttribute('style')); + out.buf += emitStylesOpen(s); + walkChildren(node, out); + out.buf += emitStylesClose(s); + return; + } + + //

    : becomes

    or passes through + if (name === 'div') { + const s = parseCssStyle(node.getAttribute('style')); + if (isPurelyInline(node)) { + const pb = { buf: '' }; + for (const dc of Array.from(node.childNodes)) { + if (isElement(dc) && isBrNode(dc)) { + if (pb.buf.length > 0) { + out.buf += `

    ${emitStylesOpen(s)}${pb.buf}${emitStylesClose(s)}

    `; + } else { + out.buf += '
    '; + } + pb.buf = ''; + continue; + } + walkNode(dc, pb); + } + if (pb.buf.length > 0) { + out.buf += `

    ${emitStylesOpen(s)}${pb.buf}${emitStylesClose(s)}

    `; + } + } else { + out.buf += emitStylesOpen(s); + walkChildren(node, out); + out.buf += emitStylesClose(s); + } + return; + } + + // Table elements + if (TABLE_TAGS.has(name)) { + if (name === 'td' || name === 'th') { + walkChildren(node, out); + // Append space if there is a next element sibling + let sib: Node | null = node.nextSibling; + while (sib && !isElement(sib)) sib = sib.nextSibling; + if (sib) out.buf += ' '; + } else if (name === 'tr') { + const row = { buf: '' }; + walkChildren(node, row); + if (row.buf.length > 0) { + out.buf += `

    ${row.buf}

    `; + } + } else { + walkChildren(node, out); + } + return; + } + + if (cls === 'pass' || cls === 'skip') { + walkChildren(node, out); + return; + } + + if (cls === 'self-closing') { + out.buf += `<${outName}${emitAttributes(node, outName)}`; + out.buf += outName === 'img' ? ' />' : '>'; + return; + } + + // inline or block + const es = extraStyles(parseCssStyle(node.getAttribute('style')), outName); + + //
  • : flatten + if (outName === 'li') { + const nestedLists: Element[] = []; + const liIb = { buf: '' }; + const ctx: LiCtx = { el: node, styles: es, nestedLists }; + flattenLiChildren(node, liIb, out, ctx); + flushLiBuffer(liIb, out, ctx); + for (const nl of nestedLists) walkChildren(nl, out); + return; + } + + // : wrap inline content in

    + if (outName === 'codeblock') { + const wrap = isPurelyInline(node); + out.buf += ''; + if (wrap) out.buf += '

    '; + walkChildren(node, out); + if (wrap) out.buf += '

    '; + out.buf += '
    '; + return; + } + + // Generic block/inline + out.buf += `<${outName}${emitAttributes(node, outName)}>`; + out.buf += emitStylesOpen(es); + walkChildren(node, out); + out.buf += emitStylesClose(es); + out.buf += ``; +} + +export function normalizeHtml(html: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(`${html}`, 'text/html'); + const body = doc.body; + const out = { buf: '' }; + walkChildren(body, out); + return out.buf; +} diff --git a/src/web/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts similarity index 82% rename from src/web/tiptapHtmlNormalizer.ts rename to src/web/normalization/tiptapHtmlNormalizer.ts index 5f9e0a3c5..9ebd2c653 100644 --- a/src/web/tiptapHtmlNormalizer.ts +++ b/src/web/normalization/tiptapHtmlNormalizer.ts @@ -2,8 +2,15 @@ import { checkboxHtmlForTiptap, checkboxHtmlFromTiptap, } from './checkboxHtmlNormalizer'; +import { normalizeHtml } from './htmlNormalizer'; -export function prepareHtmlForTiptap(html: string): string { +export function prepareHtmlForTiptap( + html: string, + useHtmlNormalizer: boolean | undefined +): string { + if (useHtmlNormalizer) { + html = normalizeHtml(html); + } html = checkboxHtmlForTiptap(html); html = html.replace(//gi, '

    '); return html; diff --git a/src/web/useOnChangeHtml.ts b/src/web/useOnChangeHtml.ts index 8953cec95..90d159d97 100644 --- a/src/web/useOnChangeHtml.ts +++ b/src/web/useOnChangeHtml.ts @@ -2,7 +2,7 @@ import { type Editor } from '@tiptap/react'; import type { OnChangeHtmlEvent } from '../types'; import type { NativeSyntheticEvent } from 'react-native'; import { useOnEditorChange } from './useOnEditorChange'; -import { normalizeHtmlFromTiptap } from './tiptapHtmlNormalizer'; +import { normalizeHtmlFromTiptap } from './normalization/tiptapHtmlNormalizer'; export const useOnChangeHtml = ( editor: Editor, diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts index 439f685de..6f4fcb175 100644 --- a/src/web/useOnChangeState.ts +++ b/src/web/useOnChangeState.ts @@ -98,6 +98,10 @@ function buildState( isBlocking: isFormatBlocked('image', editor, htmlStyle), }, alignment: 'left', + customStyle: { + foregroundColor: '', + backgroundColor: '', + }, }; } diff --git a/yarn.lock b/yarn.lock index 46bce3472..3422547d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4304,6 +4304,13 @@ __metadata: languageName: node linkType: hard +"@tootallnate/once@npm:2": + version: 2.0.1 + resolution: "@tootallnate/once@npm:2.0.1" + checksum: 10c0/23b01a341485be711c602077936d70f8e695405bb88ab4433dc6d1e6cb4556401518789574d399eded790b70b27738136c9a8f02df7ae4219f4ba28bb22d586b + languageName: node + linkType: hard + "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" @@ -4419,6 +4426,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^20.0.0": + version: 20.0.1 + resolution: "@types/jsdom@npm:20.0.1" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 10c0/3d4b2a3eab145674ee6da482607c5e48977869109f0f62560bf91ae1a792c9e847ac7c6aaf243ed2e97333cb3c51aef314ffa54a19ef174b8f9592dfcb836b25 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -4523,6 +4541,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10c0/68c6921721a3dcb40451543db2174a145ef915bc8bcbe7ad4e59194a0238e776e782b896c7a59f4b93ac6acefca9161fccb31d1ce3b3445cb6faa467297fb473 + 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" @@ -4897,6 +4922,13 @@ __metadata: languageName: node linkType: hard +"abab@npm:^2.0.6": + version: 2.0.6 + resolution: "abab@npm:2.0.6" + checksum: 10c0/0b245c3c3ea2598fe0025abf7cc7bb507b06949d51e8edae5d12c1b847a0a0c09639abcb94788332b4e2044ac4491c1e8f571b51c7826fd4b0bda1685ad4a278 + languageName: node + linkType: hard + "abbrev@npm:^3.0.0": version: 3.0.1 resolution: "abbrev@npm:3.0.1" @@ -4933,6 +4965,16 @@ __metadata: languageName: node linkType: hard +"acorn-globals@npm:^7.0.0": + version: 7.0.1 + resolution: "acorn-globals@npm:7.0.1" + dependencies: + acorn: "npm:^8.1.0" + acorn-walk: "npm:^8.0.2" + checksum: 10c0/7437f58e92d99292dbebd0e79531af27d706c9f272f31c675d793da6c82d897e75302a8744af13c7f7978a8399840f14a353b60cf21014647f71012982456d2b + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -4942,6 +4984,24 @@ __metadata: languageName: node linkType: hard +"acorn-walk@npm:^8.0.2": + version: 8.3.5 + resolution: "acorn-walk@npm:8.3.5" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10c0/e31bf5b5423ed1349437029d66d708b9fbd1b77a644b031501e2c753b028d13b56348210ed901d5b1d0d86eb3381c0a0fc0d0998511a9d546d1194936266a332 + languageName: node + linkType: hard + +"acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.8.1": + version: 8.16.0 + resolution: "acorn@npm:8.16.0" + bin: + acorn: bin/acorn + checksum: 10c0/c9c52697227661b68d0debaf972222d4f622aa06b185824164e153438afa7b08273432ca43ea792cadb24dada1d46f6f6bb1ef8de9956979288cc1b96bf9914e + languageName: node + linkType: hard + "acorn@npm:^8.14.0, acorn@npm:^8.8.2": version: 8.14.1 resolution: "acorn@npm:8.14.1" @@ -4958,6 +5018,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": version: 7.1.3 resolution: "agent-base@npm:7.1.3" @@ -5305,6 +5374,13 @@ __metadata: languageName: node linkType: hard +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + "atomically@npm:^2.0.3": version: 2.0.3 resolution: "atomically@npm:2.0.3" @@ -6024,6 +6100,15 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + "command-exists@npm:^1.2.8": version: 1.2.9 resolution: "command-exists@npm:1.2.9" @@ -6450,6 +6535,29 @@ __metadata: languageName: node linkType: hard +"cssom@npm:^0.5.0": + version: 0.5.0 + resolution: "cssom@npm:0.5.0" + checksum: 10c0/8c4121c243baf0678c65dcac29b201ff0067dfecf978de9d5c83b2ff127a8fdefd2bfd54577f5ad8c80ed7d2c8b489ae01c82023545d010c4ecb87683fb403dd + languageName: node + linkType: hard + +"cssom@npm:~0.3.6": + version: 0.3.8 + resolution: "cssom@npm:0.3.8" + checksum: 10c0/d74017b209440822f9e24d8782d6d2e808a8fdd58fa626a783337222fe1c87a518ba944d4c88499031b4786e68772c99dfae616638d71906fe9f203aeaf14411 + languageName: node + linkType: hard + +"cssstyle@npm:^2.3.0": + version: 2.3.0 + resolution: "cssstyle@npm:2.3.0" + dependencies: + cssom: "npm:~0.3.6" + checksum: 10c0/863400da2a458f73272b9a55ba7ff05de40d850f22eb4f37311abebd7eff801cf1cd2fb04c4c92b8c3daed83fe766e52e4112afb7bc88d86c63a9c2256a7d178 + languageName: node + linkType: hard + "csstype@npm:^3.0.2": version: 3.1.3 resolution: "csstype@npm:3.1.3" @@ -6478,6 +6586,17 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^3.0.2": + version: 3.0.2 + resolution: "data-urls@npm:3.0.2" + dependencies: + abab: "npm:^2.0.6" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + checksum: 10c0/051c3aaaf3e961904f136aab095fcf6dff4db23a7fc759dd8ba7b3e6ba03fc07ef608086caad8ab910d864bd3b5e57d0d2f544725653d77c96a2c971567045f4 + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.2": version: 1.0.2 resolution: "data-view-buffer@npm:1.0.2" @@ -6563,6 +6682,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.4.2": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa + languageName: node + linkType: hard + "dedent@npm:^0.7.0": version: 0.7.0 resolution: "dedent@npm:0.7.0" @@ -6714,6 +6840,13 @@ __metadata: languageName: node linkType: hard +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -6767,6 +6900,15 @@ __metadata: languageName: node linkType: hard +"domexception@npm:^4.0.0": + version: 4.0.0 + resolution: "domexception@npm:4.0.0" + dependencies: + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/774277cd9d4df033f852196e3c0077a34dbd15a96baa4d166e0e47138a80f4c0bdf0d94e4703e6ff5883cec56bb821a6fff84402d8a498e31de7c87eb932a294 + languageName: node + linkType: hard + "dot-prop@npm:^5.1.0": version: 5.3.0 resolution: "dot-prop@npm:5.3.0" @@ -6884,6 +7026,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -7211,7 +7360,7 @@ __metadata: languageName: node linkType: hard -"escodegen@npm:^2.1.0": +"escodegen@npm:^2.0.0, escodegen@npm:^2.1.0": version: 2.1.0 resolution: "escodegen@npm:2.1.0" dependencies: @@ -7932,6 +8081,19 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b + languageName: node + linkType: hard + "fresh@npm:0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -8544,6 +8706,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^3.0.0": + version: 3.0.0 + resolution: "html-encoding-sniffer@npm:3.0.0" + dependencies: + whatwg-encoding: "npm:^2.0.0" + checksum: 10c0/b17b3b0fb5d061d8eb15121c3b0b536376c3e295ecaf09ba48dd69c6b6c957839db124fe1e2b3f11329753a4ee01aa7dedf63b7677999e86da17fbbdd82c5386 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -8571,6 +8742,17 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" + dependencies: + "@tootallnate/once": "npm:2" + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/32a05e413430b2c1e542e5c74b38a9f14865301dd69dff2e53ddb684989440e3d2ce0c4b64d25eb63cf6283e6265ff979a61cf93e3ca3d23047ddfdc8df34a32 + languageName: node + linkType: hard + "http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" @@ -8581,6 +8763,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.5, https-proxy-agent@npm:^7.0.6": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -8621,7 +8813,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -9148,6 +9340,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + "is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" @@ -9598,6 +9797,27 @@ __metadata: languageName: node linkType: hard +"jest-environment-jsdom@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-jsdom@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/jsdom": "npm:^20.0.0" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jsdom: "npm:^20.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/139b94e2c8ec1bb5a46ce17df5211da65ce867354b3fd4e00fa6a0d1da95902df4cf7881273fc6ea937e5c325d39d6773f0d41b6c469363334de9d489d2c321f + languageName: node + linkType: hard + "jest-environment-node@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" @@ -9966,6 +10186,45 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^20.0.0": + version: 20.0.3 + resolution: "jsdom@npm:20.0.3" + dependencies: + abab: "npm:^2.0.6" + acorn: "npm:^8.8.1" + acorn-globals: "npm:^7.0.0" + cssom: "npm:^0.5.0" + cssstyle: "npm:^2.3.0" + data-urls: "npm:^3.0.2" + decimal.js: "npm:^10.4.2" + domexception: "npm:^4.0.0" + escodegen: "npm:^2.0.0" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.2" + parse5: "npm:^7.1.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^4.1.2" + w3c-xmlserializer: "npm:^4.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + ws: "npm:^8.11.0" + xml-name-validator: "npm:^4.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/b109073bb826a966db7828f46cb1d7371abecd30f182b143c52be5fe1ed84513bbbe995eb3d157241681fcd18331381e61e3dc004d4949f3a63bca02f6214902 + languageName: node + linkType: hard + "jsesc@npm:^3.0.2": version: 3.1.0 resolution: "jsesc@npm:3.1.0" @@ -11030,7 +11289,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:2.1.35, mime-types@npm:^2.1.27, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -11419,6 +11678,13 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.2": + version: 2.2.24 + resolution: "nwsapi@npm:2.2.24" + checksum: 10c0/9bc04ee9c7698f1b5506778d36f7382962f71667205d441d6a50f6180ee92328e770b76be78b907817ee103241b29984d3a17ae387e4723aebe0aeaed7a7c3a1 + languageName: node + linkType: hard + "ob1@npm:0.83.1": version: 0.83.1 resolution: "ob1@npm:0.83.1" @@ -11874,6 +12140,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0, parse5@npm:^7.1.1": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10c0/7fd2e4e247e85241d6f2a464d0085eed599a26d7b0a5233790c49f53473232eb85350e8133344d9b3fd58b89339e7ad7270fe1f89d28abe50674ec97b87f80b5 + languageName: node + linkType: hard + "parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -12347,6 +12622,15 @@ __metadata: languageName: node linkType: hard +"psl@npm:^1.1.33": + version: 1.15.0 + resolution: "psl@npm:1.15.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/d8d45a99e4ca62ca12ac3c373e63d80d2368d38892daa40cfddaa1eb908be98cd549ac059783ef3a56cfd96d57ae8e2fd9ae53d1378d90d42bc661ff924e102a + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.2 resolution: "pump@npm:3.0.2" @@ -12364,7 +12648,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 @@ -12396,6 +12680,13 @@ __metadata: languageName: node linkType: hard +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -12594,6 +12885,7 @@ __metadata: eslint-config-prettier: "npm:^10.1.1" eslint-plugin-prettier: "npm:^5.2.3" jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" playwright: "npm:1.58.2" prettier: "npm:^3.0.3" react: "npm:19.1.0" @@ -13032,6 +13324,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -13341,6 +13640,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + "scheduler@npm:0.26.0, scheduler@npm:^0.26.0": version: 0.26.0 resolution: "scheduler@npm:0.26.0" @@ -14034,6 +14342,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "synckit@npm:^0.11.0": version: 0.11.4 resolution: "synckit@npm:0.11.4" @@ -14163,6 +14478,27 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^4.1.2": + version: 4.1.4 + resolution: "tough-cookie@npm:4.1.4" + dependencies: + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 10c0/aca7ff96054f367d53d1e813e62ceb7dd2eda25d7752058a74d64b7266fd07be75908f3753a32ccf866a2f997604b414cfb1916d6e7f69bc64d9d9939b0d6c45 + languageName: node + linkType: hard + +"tr46@npm:^3.0.0": + version: 3.0.0 + resolution: "tr46@npm:3.0.0" + dependencies: + punycode: "npm:^2.1.1" + checksum: 10c0/cdc47cad3a9d0b6cb293e39ccb1066695ae6fdd39b9e4f351b010835a1f8b4f3a6dc3a55e896b421371187f22b48d7dac1b693de4f6551bdef7b6ab6735dfe3b + languageName: node + linkType: hard + "trim-newlines@npm:^4.0.2": version: 4.1.1 resolution: "trim-newlines@npm:4.1.1" @@ -14577,6 +14913,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: 10c0/cedbe4d4ca3967edf24c0800cfc161c5a15e240dac28e3ce575c689abc11f2c81ccc6532c8752af3b40f9120fb5e454abecd359e164f4f6aa44c29cd37e194fe + languageName: node + linkType: hard + "universalify@npm:^2.0.0": version: 2.0.1 resolution: "universalify@npm:2.0.1" @@ -14639,6 +14982,16 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87 + languageName: node + linkType: hard + "use-sync-external-store@npm:^1.4.0": version: 1.6.0 resolution: "use-sync-external-store@npm:1.6.0" @@ -14759,6 +15112,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^4.0.0": + version: 4.0.0 + resolution: "w3c-xmlserializer@npm:4.0.0" + dependencies: + xml-name-validator: "npm:^4.0.0" + checksum: 10c0/02cc66d6efc590bd630086cd88252444120f5feec5c4043932b0d0f74f8b060512f79dc77eb093a7ad04b4f02f39da79ce4af47ceb600f2bf9eacdc83204b1a8 + languageName: node + linkType: hard + "walker@npm:^1.0.7, walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" @@ -14777,6 +15139,22 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/91b90a49f312dc751496fd23a7e68981e62f33afe938b97281ad766235c4872fc4e66319f925c5e9001502b3040dd25a33b02a9c693b73a4cbbfdc4ad10c3e3e + languageName: node + linkType: hard + "whatwg-fetch@npm:^3.0.0": version: 3.6.20 resolution: "whatwg-fetch@npm:3.6.20" @@ -14784,6 +15162,23 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f + languageName: node + linkType: hard + +"whatwg-url@npm:^11.0.0": + version: 11.0.0 + resolution: "whatwg-url@npm:11.0.0" + dependencies: + tr46: "npm:^3.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/f7ec264976d7c725e0696fcaf9ebe056e14422eacbf92fdbb4462034609cba7d0c85ffa1aab05e9309d42969bcf04632ba5ed3f3882c516d7b093053315bf4c1 + languageName: node + linkType: hard + "when-exit@npm:^2.1.1": version: 2.1.4 resolution: "when-exit@npm:2.1.4" @@ -15005,6 +15400,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.11.0": + version: 8.21.0 + resolution: "ws@npm:8.21.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/ef4a243476283fc49bc7550966c4af4aa0eef56273837211e700de3b664e08604a760cdddcb5ba43c049140e74ccfec5b0ee0bb439e08c2adf9138902fdde5f9 + languageName: node + linkType: hard + "xdg-basedir@npm:^5.1.0": version: 5.1.0 resolution: "xdg-basedir@npm:5.1.0" @@ -15012,6 +15422,20 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^4.0.0": + version: 4.0.0 + resolution: "xml-name-validator@npm:4.0.0" + checksum: 10c0/c1bfa219d64e56fee265b2bd31b2fcecefc063ee802da1e73bad1f21d7afd89b943c9e2c97af2942f60b1ad46f915a4c81e00039c7d398b53cf410e29d3c30bd + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3"