From 0dc84de7dfafb8fe0d484864afa06d41532a58d5 Mon Sep 17 00:00:00 2001 From: Abdullah1738 Date: Tue, 13 Oct 2026 00:53:47 +0400 Subject: [PATCH 1/8] feat: add a line wrap toggle to code block headers --- docs-md/API_REFERENCE.md | 8 + .../enriched/markdown/EnrichedMarkdown.kt | 40 ++++ .../markdown/EnrichedMarkdownManager.kt | 8 + .../enriched/markdown/MeasurementStore.kt | 28 ++- .../segments/CodeBlockContainerView.kt | 161 +++++++++++-- .../EnrichedMarkdownTextSpec/conversions.h | 2 + .../ios/EnrichedMarkdown.h | 5 + .../ios/EnrichedMarkdown.mm | 60 +++++ .../ios/internals/ENRMViewFreeMeasurement.h | 16 +- .../internals/EnrichedMarkdownShadowNode.mm | 17 +- .../ios/internals/ShadowMeasurementUtils.h | 9 +- .../ios/segments/ENRMCodeBlockContainerView.h | 18 ++ .../ios/segments/ENRMCodeBlockContainerView.m | 220 ++++++++++++++++-- .../package.json | 2 +- .../src/EnrichedMarkdownNativeComponent.ts | 6 + .../src/native/EnrichedMarkdownText.tsx | 3 + .../src/types/MarkdownTextProps.ts | 11 + .../src/web/nativeProps.ts | 1 + 18 files changed, 565 insertions(+), 50 deletions(-) diff --git a/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md index d64f1d10d..887ed437e 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -282,6 +282,14 @@ Works in both flavors: `flavor="github"` fires on the container-based code block /> ``` +### `enableCodeBlockWrapToggle` + +Adds a line wrap toggle next to the copy button in each fenced code block header (`flavor="github"`). Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. Each block keeps its own state across streaming updates, but not across a remount. Code blocks inside blockquotes keep scrolling. VoiceOver and TalkBack announce it as "Wrap lines" / "Disable line wrap". + +| Type | Default Value | Platform | +| --------- | ------------- | ------------ | +| `boolean` | `false` | iOS, Android | + ### `enableBlockContextMenu` Controls the long-press copy popup on code blocks, tables, block math, and blockquotes/admonitions. diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index 8ac85e9d8..5e772331d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -244,6 +244,45 @@ class EnrichedMarkdown( dynamicProps.enableCodeBlockPress = value } + // Document-order indexes of the top-level code blocks the user wrapped. Kept + // across renders so streaming and segment recreation keep the choice, and + // mirrored into MeasurementStore so the Yoga measure pass sees it. + private var wrappedCodeBlocks: Set = emptySet() + + var enableCodeBlockWrapToggle: Boolean = false + set(value) { + if (field == value) return + field = value + val hadWrappedBlocks = wrappedCodeBlocks.isNotEmpty() + if (!value) setWrappedCodeBlocks(emptySet()) + syncCodeBlockWrapState() + if (hadWrappedBlocks && !value) onImageLayoutChanged() + } + + private fun setWrappedCodeBlocks(value: Set) { + wrappedCodeBlocks = value + MeasurementStore.updateWrappedCodeBlocks(id, value) + } + + private fun syncCodeBlockWrapState() { + var codeBlockIndex = 0 + segmentViews.forEach { view -> + if (view is CodeBlockContainerView) { + view.onWrapToggle = ::codeBlockDidToggleWrap + view.showsWrapButton = enableCodeBlockWrapToggle + view.wrapped = enableCodeBlockWrapToggle && codeBlockIndex in wrappedCodeBlocks + codeBlockIndex++ + } + } + } + + private fun codeBlockDidToggleWrap(view: CodeBlockContainerView) { + val codeBlockIndex = segmentViews.filterIsInstance().indexOf(view) + if (codeBlockIndex < 0) return + setWrappedCodeBlocks(if (view.wrapped) wrappedCodeBlocks + codeBlockIndex else wrappedCodeBlocks - codeBlockIndex) + onImageLayoutChanged() + } + init { segmentViewFactory = RootFactory() } @@ -604,6 +643,7 @@ class EnrichedMarkdown( view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1 } } + syncCodeBlockWrapState() if (width > 0) { val heightBefore = computeSegmentsTotalHeight() diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index 74ce4a8f3..bec4d4929 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -330,6 +330,14 @@ class EnrichedMarkdownManager : view?.enableCodeBlockPress = enableCodeBlockPress } + @ReactProp(name = "enableCodeBlockWrapToggle", defaultBoolean = false) + override fun setEnableCodeBlockWrapToggle( + view: EnrichedMarkdown?, + enableCodeBlockWrapToggle: Boolean, + ) { + view?.enableCodeBlockWrapToggle = enableCodeBlockWrapToggle + } + @ReactProp(name = "lineBreakStrategyIOS") override fun setLineBreakStrategyIOS( view: EnrichedMarkdown?, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt index 09d723563..97656c5f0 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -95,6 +95,9 @@ object MeasurementStore { private val streamingCodeBlockModes = ConcurrentHashMap() + // Top-level code blocks the user wrapped, by document-order index (see EnrichedMarkdown). + private val wrappedCodeBlocks = ConcurrentHashMap>() + private fun resolveFontScalingSettings( viewId: Int?, props: ReadableMap?, @@ -147,6 +150,15 @@ object MeasurementStore { data.remove(id) numberOfLinesByViewId.remove(id) ellipsizeModeByViewId.remove(id) + wrappedCodeBlocks.remove(id) + } + + fun updateWrappedCodeBlocks( + viewId: Int, + indices: Set, + ) { + if (indices.isEmpty()) wrappedCodeBlocks.remove(viewId) else wrappedCodeBlocks[viewId] = indices + data.remove(viewId) } fun invalidate(id: Int) { @@ -491,7 +503,16 @@ object MeasurementStore { } else { rawMarkdown } - val propsHash = computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + val wrapped = + if (props.getBooleanOrDefault("enableCodeBlockWrapToggle", false)) { + id?.let { wrappedCodeBlocks[it] } ?: emptySet() + } else { + emptySet() + } + // A Set's hashCode sums its elements, so {} and {0} would collide; the sorted list's doesn't. + val propsHash = + 31 * computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + + wrapped.sorted().hashCode() // Streaming shortcut: reuse cached size when the filtered content and // width are unchanged. When the filter output changes (e.g. a table @@ -589,6 +610,7 @@ object MeasurementStore { val lastIndex = renderedSegments.lastIndex var totalHeightPx = 0f var maxContentWidthPx = 0f + var codeBlockIndex = 0 for ((index, segment) in renderedSegments.withIndex()) { val isLastSegment = index == lastIndex @@ -629,7 +651,9 @@ object MeasurementStore { is RenderedSegment.CodeBlock -> { totalHeightPx += style.codeBlockStyle.marginTop - totalHeightPx += CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width) + totalHeightPx += + CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width, codeBlockIndex in wrapped) + codeBlockIndex++ maxContentWidthPx = width if (includeBottomMargin) { totalHeightPx += style.codeBlockStyle.marginBottom diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt index cba082576..28403e346 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt @@ -6,6 +6,7 @@ import android.content.Context import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.Paint +import android.graphics.Path import android.graphics.PixelFormat import android.graphics.Typeface import android.graphics.drawable.Drawable @@ -25,6 +26,7 @@ import android.widget.HorizontalScrollView import android.widget.ImageView import androidx.appcompat.widget.AppCompatImageButton import androidx.appcompat.widget.AppCompatTextView +import androidx.core.graphics.PathParser import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.LineHeightSpan import com.swmansion.enriched.markdown.styles.CodeBlockStyle @@ -47,8 +49,9 @@ import kotlin.math.roundToInt * * measureCodeBlockNodeHeight must stay in sync with the view: it builds the * same styled text, paint, and header metrics, so the height reported to - * Yoga matches the laid-out height. Since lines never wrap, the height is - * independent of the available width. + * Yoga matches the laid-out height. Unless the block is wrapped (the optional + * header wrap toggle), lines never wrap and the height is independent of the + * available width. */ class CodeBlockContainerView( context: Context, @@ -72,6 +75,7 @@ class CodeBlockContainerView( private var code: String = "" private var language: String? = null private var fenceChar: String = "`" + private var appliedTextMaxWidth = Int.MAX_VALUE // True until the closing fence arrives: highlighting is deferred and copying // is disabled, while the header stays visible. @@ -85,6 +89,30 @@ class CodeBlockContainerView( rebuildCodeText() } + // Shows the header wrap toggle next to the copy button. Set by the root host + // from the enableCodeBlockWrapToggle prop. + var showsWrapButton: Boolean = false + set(value) { + if (field == value) return + field = value + wrapButton.visibility = if (value) View.VISIBLE else View.GONE + requestLayout() + } + + // Wraps long lines at the block width instead of scrolling horizontally. + var wrapped: Boolean = false + set(value) { + if (field == value) return + field = value + updateWrapButtonAppearance() + if (value) scrollView.scrollTo(0, 0) + requestLayout() + } + + // Fired after the header toggle flips [wrapped], so the host can remember the + // choice and re-measure. + var onWrapToggle: ((CodeBlockContainerView) -> Unit)? = null + private val textView = AppCompatTextView(context).apply { includeFontPadding = false @@ -142,6 +170,17 @@ class CodeBlockContainerView( setOnClickListener { copyCode() } } + private val wrapIcon = WrapIconDrawable(codeBlockStyle.color, ceil(codeBlockStyle.fontSize * HEADER_ICON_SCALE).toInt()) + + private val wrapButton = + AppCompatImageButton(context).apply { + background = null + scaleType = ImageView.ScaleType.CENTER + visibility = View.GONE + setImageDrawable(wrapIcon) + setOnClickListener { toggleWrap() } + } + private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = dividerColor(codeBlockStyle.color) @@ -155,7 +194,7 @@ class CodeBlockContainerView( override fun onDown(e: MotionEvent): Boolean = true override fun onSingleTapUp(e: MotionEvent): Boolean { - if (isPointInsideCopyButton(e.x, e.y)) return false + if (isPointInsideHeaderButton(e.x, e.y)) return false handleCodeBlockPress() return false } @@ -177,6 +216,19 @@ class CodeBlockContainerView( addView(scrollView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) addView(languageView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) addView(copyButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + addView(wrapButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + updateWrapButtonAppearance() + } + + private fun updateWrapButtonAppearance() { + // Full-strength tint marks the toggle as on; off matches the copy button. + wrapIcon.setColor(if (wrapped) codeBlockStyle.color else secondaryColor(codeBlockStyle.color)) + wrapButton.contentDescription = if (wrapped) DISABLE_LINE_WRAP_LABEL else WRAP_LINES_LABEL + } + + private fun toggleWrap() { + wrapped = !wrapped + onWrapToggle?.invoke(this) } fun applyCodeBlockNode(node: MarkdownASTNode) { @@ -206,15 +258,22 @@ class CodeBlockContainerView( heightSpec: Int, ) { val measuredWidth = MeasureSpec.getSize(widthSpec) + val paneWidth = (measuredWidth - borderW * 2).coerceAtLeast(0) + // The scroll view measures its child unbounded; a max width makes the text wrap inside the pane. + val textMaxWidth = if (wrapped) paneWidth.coerceAtLeast(1) else Int.MAX_VALUE + if (textMaxWidth != appliedTextMaxWidth) { + appliedTextMaxWidth = textMaxWidth + textView.maxWidth = textMaxWidth + } scrollView.measure( - MeasureSpec.makeMeasureSpec((measuredWidth - borderW * 2).coerceAtLeast(0), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(paneWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), ) - copyButton.measure( - MeasureSpec.makeMeasureSpec(headerH, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(headerH, MeasureSpec.EXACTLY), - ) - val labelMaxWidth = (measuredWidth - inset * 2 - headerH).coerceAtLeast(0) + val buttonSpec = MeasureSpec.makeMeasureSpec(headerH, MeasureSpec.EXACTLY) + copyButton.measure(buttonSpec, buttonSpec) + wrapButton.measure(buttonSpec, buttonSpec) + val headerButtonsWidth = if (showsWrapButton) headerH * 2 else headerH + val labelMaxWidth = (measuredWidth - inset * 2 - headerButtonsWidth).coerceAtLeast(0) languageView.measure( MeasureSpec.makeMeasureSpec(labelMaxWidth, MeasureSpec.AT_MOST), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), @@ -250,6 +309,9 @@ class CodeBlockContainerView( buttonLeft + copyButton.measuredWidth, buttonTop + copyButton.measuredHeight, ) + // The wrap toggle sits one button-width left of copy, in the same row. + val wrapLeft = (buttonLeft - wrapButton.measuredWidth).coerceAtLeast(0) + wrapButton.layout(wrapLeft, buttonTop, wrapLeft + wrapButton.measuredWidth, buttonTop + wrapButton.measuredHeight) scrollView.layout(borderW, headerH, width - borderW, bottom - top - borderW) } @@ -290,10 +352,16 @@ class CodeBlockContainerView( return super.dispatchTouchEvent(ev) } - private fun isPointInsideCopyButton( + private fun isPointInsideHeaderButton( x: Float, y: Float, - ): Boolean = x >= copyButton.left && x < copyButton.right && y >= copyButton.top && y < copyButton.bottom + ): Boolean = isPointInside(copyButton, x, y) || (wrapButton.visibility == View.VISIBLE && isPointInside(wrapButton, x, y)) + + private fun isPointInside( + view: View, + x: Float, + y: Float, + ): Boolean = x >= view.left && x < view.right && y >= view.top && y < view.bottom private fun copyFencedMarkdown() { if (code.isEmpty()) return @@ -340,7 +408,57 @@ class CodeBlockContainerView( override fun getOpacity() = PixelFormat.TRANSLUCENT } + // Material "wrap_text" glyph, drawn from its 24-unit path at the copy glyph's size. + private class WrapIconDrawable( + color: Int, + private val size: Int, + ) : Drawable() { + private val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + this.color = color + } + + fun setColor(color: Int) { + paint.color = color + invalidateSelf() + } + + override fun draw(canvas: Canvas) { + val scale = bounds.width() / 24f + val saved = canvas.save() + canvas.translate(bounds.left.toFloat(), bounds.top.toFloat()) + canvas.scale(scale, scale) + canvas.drawPath(WRAP_TEXT_PATH, paint) + canvas.restoreToCount(saved) + } + + override fun getIntrinsicWidth() = size + + override fun getIntrinsicHeight() = size + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity() = PixelFormat.TRANSLUCENT + } + companion object { + private const val WRAP_LINES_LABEL = "Wrap lines" + private const val DISABLE_LINE_WRAP_LABEL = "Disable line wrap" + + private val WRAP_TEXT_PATH: Path by lazy { + PathParser.createPathFromPathData( + "M4 19h6v-2H4v2zM20 5H4v2h16V5zm-3 6H4v2h13.25c1.1 0 2 .9 2 2s-.9 2-2 2H15v-2l-3 3 3 3v-2h2c2.21 0 4-1.79 4-4s-1.79-4-4-4z", + ) + } + private const val HEADER_LABEL_SCALE = 0.85f // Drawable box for the copy glyph, as a multiple of the code font size. @@ -389,14 +507,16 @@ class CodeBlockContainerView( } } - // Lines never wrap (the code pane scrolls horizontally), so the layout is - // built at the text's desired width and the result is independent of the - // available width. + // Unwrapped lines never wrap (the code pane scrolls horizontally), so the + // layout is built at the text's desired width and the result is independent + // of the available width. A wrapped block lays out at the pane's text width + // instead, the same width the view caps its text view at. fun measureCodeBlockNodeHeight( node: MarkdownASTNode, config: StyleConfig, context: Context, - @Suppress("unused") width: Float, + width: Float, + wrapped: Boolean = false, ): Float { val style = config.codeBlockStyle val inset = contentInset(style) @@ -404,7 +524,16 @@ class CodeBlockContainerView( val text = buildCodeText(CodeBlockNode.extractCode(node), style) if (text.isEmpty()) return headerH + inset * 2f val paint = createCodePaint(style, context) - val contentWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt().coerceAtLeast(1) + val layoutWidth = + if (wrapped) { + val borderW = borderPx(style) + val horizontalPad = (inset - borderW).coerceAtLeast(0) + // Mirrors onMeasure: the text view is capped at the pane width (at least 1px), padding included. + (ceil(width).toInt() - borderW * 2).coerceAtLeast(1) - horizontalPad * 2 + } else { + ceil(Layout.getDesiredWidth(text, paint)).toInt() + } + val contentWidth = layoutWidth.coerceAtLeast(1) val layout = StaticLayout.Builder .obtain(text, 0, text.length, paint, contentWidth) diff --git a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h index daca4ce8b..fd78a9446 100644 --- a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +++ b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h @@ -47,6 +47,8 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownProps &props) { serializedProps["maxFontSizeMultiplier"] = props.maxFontSizeMultiplier; serializedProps["allowTrailingMargin"] = props.allowTrailingMargin; serializedProps["streamingAnimation"] = props.streamingAnimation; + // Wrapped code blocks measure at the block width; the indexes live in MeasurementStore. + serializedProps["enableCodeBlockWrapToggle"] = props.enableCodeBlockWrapToggle; serializedProps["enableImageSourceResolution"] = props.enableImageSourceResolution; serializedProps["imageSourcesRevision"] = props.imageSourcesRevision; diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h index 99ea444f3..f13434d8a 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h @@ -20,6 +20,11 @@ NS_ASSUME_NONNULL_BEGIN /// Backed by a single lock-free atomic, so the pair can't tear. Returns /// CGSizeZero until the first layout is committed. - (CGSize)lastCommittedLayoutSize; + +/// Document-order indexes of the top-level code blocks the user wrapped with the +/// header toggle. Written on main, read by the shadow-node measure pass on any +/// thread (atomic copy), so the measured height follows the wrapped blocks. +@property (atomic, copy, readonly) NSSet *wrappedCodeBlockIndices; @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index 9cd82174a..54f187ec2 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -98,6 +98,7 @@ - (void)emitContextMenuItemPress:(NSString *)itemText selectedText:(NSString *)selectedText selectionStart:(NSUInteger)selectionStart selectionEnd:(NSUInteger)selectionEnd; +@property (atomic, copy, readwrite) NSSet *wrappedCodeBlockIndices; @end #if !TARGET_OS_OSX @@ -138,6 +139,7 @@ @implementation EnrichedMarkdown { ENRMCodeBlockStreamingMode _codeBlockStreamingMode; // Trailing code block whose closing fence hasn't streamed in yet, if any. ENRMCodeBlockSegment *_pendingCodeBlockSegment; + BOOL _enableCodeBlockWrapToggle; size_t _renderedStyleFingerprint; size_t _pendingStyleFingerprint; @@ -244,6 +246,7 @@ - (instancetype)initWithFrame:(CGRect)frame _enableLinkPreview = YES; _enableTaskListItemToggle = YES; _enableImagePress = NO; + self.wrappedCodeBlockIndices = [NSSet set]; _dynamicBlockProps = [[ENRMDynamicBlockProps alloc] init]; _linkContextMenus = [[ENRMLinkContextMenus alloc] init]; __weak EnrichedMarkdown *weakMenuSelf = self; @@ -1007,6 +1010,7 @@ - (void)applyRenderedSegments:(NSArray *)renderedSegments self->_pendingCodeBlockSegment != nil && i == self->_segmentViews.count - 1; } }]; + [self syncCodeBlockWrapState]; CGRect contentBounds = [self contentBounds]; if (contentBounds.size.width > 0) { @@ -1154,12 +1158,53 @@ - (ENRMCodeBlockContainerView *)createCodeBlockViewForSegment:(ENRMCodeBlockSegm if (strongSelf) [strongSelf emitCodeBlockPress:code language:language]; }; + codeBlockView.onWrapToggle = ^(ENRMCodeBlockContainerView *view) { [weakSelf codeBlockDidToggleWrap:view]; }; codeBlockView.pending = codeBlockSegment == _pendingCodeBlockSegment; [codeBlockView applyCodeBlockNode:codeBlockSegment.codeBlockNode]; return codeBlockView; } +// Wrap state is keyed by a code block's position among the top-level code +// blocks, so it survives streaming updates and segment recreation; the view-free +// measure pass reads the same indexes (see wrappedCodeBlockIndices). +- (void)syncCodeBlockWrapState +{ + NSSet *wrapped = self.wrappedCodeBlockIndices; + NSUInteger codeBlockIndex = 0; + for (RCTUIView *segment in _segmentViews) { + if (![segment isKindOfClass:[ENRMCodeBlockContainerView class]]) { + continue; + } + ENRMCodeBlockContainerView *codeBlock = (ENRMCodeBlockContainerView *)segment; + codeBlock.showsWrapButton = _enableCodeBlockWrapToggle; + codeBlock.wrapped = _enableCodeBlockWrapToggle && [wrapped containsObject:@(codeBlockIndex)]; + codeBlockIndex++; + } +} + +- (void)codeBlockDidToggleWrap:(ENRMCodeBlockContainerView *)view +{ + NSUInteger codeBlockIndex = 0; + for (RCTUIView *segment in _segmentViews) { + if (segment == view) { + NSMutableSet *wrapped = [self.wrappedCodeBlockIndices mutableCopy]; + if (view.wrapped) { + [wrapped addObject:@(codeBlockIndex)]; + } else { + [wrapped removeObject:@(codeBlockIndex)]; + } + self.wrappedCodeBlockIndices = wrapped; + [self setNeedsLayout]; + [self requestHeightUpdate]; + return; + } + if ([segment isKindOfClass:[ENRMCodeBlockContainerView class]]) { + codeBlockIndex++; + } + } +} + - (void)animateBlockViewIfNeeded:(RCTUIView *)view { if (!_streamingAnimation) @@ -1346,6 +1391,19 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _dynamicBlockProps.enableBlockContextMenu = newViewProps.enableBlockContextMenu; _dynamicBlockProps.enableCodeBlockPress = newViewProps.enableCodeBlockPress; + if (_enableCodeBlockWrapToggle != newViewProps.enableCodeBlockWrapToggle) { + _enableCodeBlockWrapToggle = newViewProps.enableCodeBlockWrapToggle; + BOOL hadWrappedBlocks = self.wrappedCodeBlockIndices.count > 0; + if (!_enableCodeBlockWrapToggle) { + self.wrappedCodeBlockIndices = [NSSet set]; + } + [self syncCodeBlockWrapState]; + if (hadWrappedBlocks && !_enableCodeBlockWrapToggle) { + [self setNeedsLayout]; + [self requestHeightUpdate]; + } + } + if (newViewProps.streamingAnimation != oldViewProps.streamingAnimation) { _streamingAnimation = newViewProps.streamingAnimation; _dirtyFlags |= ENRMDirtyForceHeight | ENRMDirtyRender; @@ -1534,6 +1592,8 @@ - (void)prepareForRecycle _tableStreamingMode = ENRMTableStreamingModeProgressive; _codeBlockStreamingMode = ENRMCodeBlockStreamingModeProgressive; _pendingCodeBlockSegment = nil; + _enableCodeBlockWrapToggle = NO; + self.wrappedCodeBlockIndices = [NSSet set]; _lineBreakStrategy = NSLineBreakStrategyNone; _writingDirectionMode = ENRMWritingDirectionModeFirstStrong; _renderedStyleFingerprint = 0; diff --git a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h index 6aa96222b..8cb91b8e9 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -233,11 +233,15 @@ static inline CGSize ENRMMeasureMarkdownViewFree(const PropsT &typedProps, CGFlo * same condition, tables report full width. Finishes with `measureSize:`'s * pixel rounding (width capped at maxWidth) and its * (maxWidth × system-16 line height) fallback for empty/failed content. + * `wrappedCodeBlocks` holds the document-order indexes of top-level code blocks + * the user wrapped (EnrichedMarkdown.wrappedCodeBlockIndices); those measure at + * the block width instead of their unwrapped height. */ template static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedProps, CGFloat maxWidth, CGFloat fontScale, CGFloat pointScaleFactor, - NSWritingDirection resolvedLayoutDirection) + NSWritingDirection resolvedLayoutDirection, + NSSet *wrappedCodeBlocks = nil) { @autoreleasepool { CGSize fallback = CGSizeMake(maxWidth, UIFontLineHeight([UIFont systemFontOfSize:16.0])); @@ -304,6 +308,7 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro CGFloat yOffset = 0.0; CGFloat maxContentWidth = 0.0; const NSUInteger lastIndex = segments.count - 1; + NSUInteger codeBlockIndex = 0; for (NSUInteger i = 0; i < segments.count; i++) { ENRMRenderedSegment *segment = segments[i]; @@ -337,8 +342,13 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro } } else if (segment.kind == ENRMSegmentKindCodeBlock && segment.codeBlockSegment) { yOffset += config.codeBlockMarginTop; - yOffset += [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:segment.codeBlockSegment.codeBlockNode - config:config]; + MarkdownASTNode *codeBlockNode = segment.codeBlockSegment.codeBlockNode; + yOffset += [wrappedCodeBlocks containsObject:@(codeBlockIndex)] + ? [ENRMCodeBlockContainerView measureWrappedHeightForCodeBlockNode:codeBlockNode + config:config + maxWidth:maxWidth] + : [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:codeBlockNode config:config]; + codeBlockIndex++; maxContentWidth = maxWidth; if (shouldAddBottomMargin) { yOffset += config.codeBlockMarginBottom; diff --git a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm index ac9b15897..7d94b0d84 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm @@ -62,13 +62,20 @@ ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight; + // Wrapped code blocks are view state, not props, so they bypass the props-keyed cache. + const auto componentViewRef = getStateData().getComponentViewRef(); + RCTInternalGenericWeakWrapper *weakWrapper = (RCTInternalGenericWeakWrapper *)unwrapManagedObject(componentViewRef); + EnrichedMarkdown *view = weakWrapper ? (EnrichedMarkdown *)weakWrapper.object : nil; + NSSet *wrappedCodeBlocks = typedProps.enableCodeBlockWrapToggle ? view.wrappedCodeBlockIndices : nil; + return ENRMMeasureMarkdownContent( - typedProps, getStateData().getComponentViewRef(), receivedCounter, lastExactMeasurementCounter_, - lastExactMeasurementSize_, MarkdownFlavor::GitHub, layoutContext, layoutConstraints, - ^(EnrichedMarkdown *view, CGFloat maxWidth, CGFloat fontScale) { + typedProps, componentViewRef, receivedCounter, lastExactMeasurementCounter_, lastExactMeasurementSize_, + MarkdownFlavor::GitHub, layoutContext, layoutConstraints, + ^(EnrichedMarkdown *measuredView, CGFloat maxWidth, CGFloat fontScale) { return ENRMMeasureSegmentedMarkdownViewFree(*props, maxWidth, fontScale, pointScaleFactor, - resolvedLayoutDirection); - }); + resolvedLayoutDirection, wrappedCodeBlocks); + }, + wrappedCodeBlocks.count == 0); } } // namespace facebook::react diff --git a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h index 48defb1bf..06b7b651a 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h +++ b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h @@ -87,14 +87,17 @@ static inline bool ENRMPropsNeedExactStreamingMeasurement(const PropsT &oldProps * `ENRMMeasureSegmentedMarkdownViewFree`); the resolved view is still handed * to the block for strategies that can use it. `fontScale` handed to the * block is the real multiplier even when streaming skips the cache — - * rendering must always use it. + * rendering must always use it. `allowMeasurementCache` lets a caller skip the + * props-keyed cache when view state outside the props changes the size (code + * blocks the user wrapped). */ template static inline Size ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr &componentViewRef, int receivedCounter, int &lastExactMeasurementCounter, CGSize &lastExactMeasurementSize, MarkdownFlavor flavor, const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints, - CGSize (^measureUncached)(ViewT *view, CGFloat maxWidth, CGFloat fontScale)) + CGSize (^measureUncached)(ViewT *view, CGFloat maxWidth, CGFloat fontScale), + bool allowMeasurementCache = true) { CGFloat maxWidth = layoutConstraints.maximumSize.width; @@ -116,7 +119,7 @@ ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr } } - const bool shouldUseMeasurementCache = !typedProps.streamingAnimation; + const bool shouldUseMeasurementCache = !typedProps.streamingAnimation && allowMeasurementCache; CGFloat fontScale = ENRMFontScaleForMeasurement(typedProps.allowFontScaling, layoutContext); if (shouldUseMeasurementCache && !typedProps.markdown.empty()) { diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h index e17d3dfb9..b383d0e86 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h @@ -4,11 +4,13 @@ #import "StyleConfig.h" @class MarkdownASTNode; +@class ENRMCodeBlockContainerView; NS_ASSUME_NONNULL_BEGIN typedef void (^ENRMCodeBlockCopyBlock)(NSString *code, NSString *language); typedef void (^ENRMCodeBlockPressBlock)(NSString *code, NSString *language); +typedef void (^ENRMCodeBlockWrapToggleBlock)(ENRMCodeBlockContainerView *view); @interface ENRMCodeBlockContainerView : RCTUIView @@ -22,8 +24,24 @@ typedef void (^ENRMCodeBlockPressBlock)(NSString *code, NSString *language); // instance's measureHeight: reports for the node, without building a view. + (CGFloat)measureHeightForCodeBlockNode:(MarkdownASTNode *)node config:(StyleConfig *)config; +// View-free height of the node with `wrapped` on, laid out at the block width. ++ (CGFloat)measureWrappedHeightForCodeBlockNode:(MarkdownASTNode *)node + config:(StyleConfig *)config + maxWidth:(CGFloat)maxWidth; + @property (nonatomic, strong) StyleConfig *config; +// Shows the header wrap toggle next to the copy button (iOS only). Set by the +// root host from the enableCodeBlockWrapToggle prop. +@property (nonatomic, assign) BOOL showsWrapButton; + +// Wraps long lines at the block width instead of scrolling horizontally. +@property (nonatomic, assign) BOOL wrapped; + +// Fired after the header toggle or the VoiceOver action flips `wrapped`, so the +// host can remember the choice and re-measure. +@property (nonatomic, copy, nullable) ENRMCodeBlockWrapToggleBlock onWrapToggle; + // True until the closing fence arrives: highlighting is deferred and copying is // disabled, while the header stays visible. @property (nonatomic, assign) BOOL pending; diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m index 53029a745..9777a5bd7 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m @@ -18,8 +18,9 @@ // // measureHeight: must stay in sync with the drawn content: it uses the // unconstrained bounding rect of the same attributed string and the same -// header metrics, so the reported height matches the drawn height. Since -// lines never wrap, the height is independent of the available width. +// header metrics, so the reported height matches the drawn height. Unless the +// block is wrapped (the optional header wrap toggle), lines never wrap and the +// height is independent of the available width. static const CGFloat kENRMHeaderLabelScale = 0.85; // The copy glyph reads cleaner a touch smaller than the language label and at a @@ -27,6 +28,8 @@ static const CGFloat kENRMHeaderIconScale = 0.72; static const CGFloat kENRMHeaderSecondaryAlpha = 0.6; static const CGFloat kENRMHeaderDividerAlpha = 0.2; +static NSString *const kENRMWrapLinesLabel = @"Wrap lines"; +static NSString *const kENRMDisableLineWrapLabel = @"Disable line wrap"; // Height geometry and code-run construction shared by the instance layout path // and the view-free measureHeightForCodeBlockNode:, so the two cannot drift. @@ -42,6 +45,12 @@ static CGFloat ENRMCodeBlockContentInset(StyleConfig *config) return [UIFont systemFontOfSize:[config codeBlockFont].pointSize * kENRMHeaderLabelScale weight:UIFontWeightMedium]; } +static UIImageSymbolConfiguration *ENRMCodeBlockHeaderSymbolConfiguration(StyleConfig *config) +{ + return [UIImageSymbolConfiguration configurationWithPointSize:[config codeBlockFont].pointSize * kENRMHeaderIconScale + weight:UIImageSymbolWeightRegular]; +} + static CGFloat ENRMCodeBlockHeaderLabelLineHeight(UIFont *headerFont) { return ceil(UIFontLineHeight(headerFont)); @@ -96,6 +105,37 @@ static CGFloat ENRMCodeBlockCodeHeight(NSString *code, StyleConfig *config) return ceil(lineCount * ENRMCodeBlockPerLineHeight(config)); } +// Width the code text gets when wrapped: the block width minus the border and +// the code pane's horizontal padding, exactly as layoutScrollArea insets it. +static CGFloat ENRMCodeBlockWrapWidth(CGFloat blockWidth, StyleConfig *config) +{ + CGFloat borderW = ceil([config codeBlockBorderWidth]); + CGFloat horizontalPad = MAX(ENRMCodeBlockContentInset(config) - borderW, 0); + return MAX(blockWidth - borderW * 2, 0) - horizontalPad * 2; +} + +// Wrapped code height: the same per-line-height arithmetic as the analytic +// height, over visual lines from a call-confined TextKit layout at the wrap width. +static CGFloat ENRMCodeBlockWrappedCodeHeight(NSAttributedString *code, CGFloat wrapWidth, StyleConfig *config) +{ + if (code.length == 0) { + return 0; + } + NSTextStorage *storage = [[NSTextStorage alloc] initWithAttributedString:code]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.usesFontLeading = ENRMLayoutManagerUsesFontLeading; + NSTextContainer *container = [[NSTextContainer alloc] initWithSize:CGSizeMake(wrapWidth, CGFLOAT_MAX)]; + container.lineFragmentPadding = 0; + [layoutManager addTextContainer:container]; + [storage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:container]; + __block NSUInteger lineCount = 0; + [layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, layoutManager.numberOfGlyphs) + usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *textContainer, + NSRange glyphRange, BOOL *stop) { lineCount++; }]; + return ceil(MAX(lineCount, 1) * ENRMCodeBlockPerLineHeight(config)); +} + // Used only to pick the scroll indicator style for the code pane, so the // indicator stays visible on dark code block backgrounds without affecting // any other scroll view. @@ -132,13 +172,16 @@ static BOOL ENRMColorIsDark(RCTUIColor *color) // the same rendering path drawAtPoint used internally, so output is identical, // but the layout is no longer thrown away on every draw. The container is // unbounded (lines never wrap) with zero line-fragment padding and font leading -// disabled, matching the NSStringDrawing origin the block used before. +// disabled, matching the NSStringDrawing origin the block used before. A +// positive wrapWidth bounds it instead, so lines wrap at that width. @interface ENRMCodeBlockContentView : RCTUIView @property (nonatomic, strong, nullable) NSAttributedString *attributedCode; @property (nonatomic, assign) CGPoint textOrigin; +@property (nonatomic, assign) CGFloat wrapWidth; // Laid-out content width, for the horizontal scroll extent. Free once the // cached stack exists; no separate measurement pass. @property (nonatomic, readonly) CGFloat usedWidth; +@property (nonatomic, readonly) CGFloat usedHeight; #if TARGET_OS_OSX @property (nonatomic, copy, nullable) NSMenu * (^menuProvider)(void); #endif @@ -174,7 +217,7 @@ - (void)setAttributedCode:(nullable NSAttributedString *)attributedCode _textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedCode]; _layoutManager = [[NSLayoutManager alloc] init]; _layoutManager.usesFontLeading = ENRMLayoutManagerUsesFontLeading; - _textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)]; + _textContainer = [[NSTextContainer alloc] initWithSize:[self containerSize]]; _textContainer.lineFragmentPadding = 0; _textContainer.maximumNumberOfLines = 0; [_layoutManager addTextContainer:_textContainer]; @@ -182,6 +225,28 @@ - (void)setAttributedCode:(nullable NSAttributedString *)attributedCode [_layoutManager ensureLayoutForTextContainer:_textContainer]; } +- (CGSize)containerSize +{ + return CGSizeMake(_wrapWidth > 0 ? _wrapWidth : CGFLOAT_MAX, CGFLOAT_MAX); +} + +- (void)setWrapWidth:(CGFloat)wrapWidth +{ + if (_wrapWidth == wrapWidth) { + return; + } + _wrapWidth = wrapWidth; + if (_textContainer) { + _textContainer.size = [self containerSize]; + [_layoutManager ensureLayoutForTextContainer:_textContainer]; + } +#if !TARGET_OS_OSX + [self setNeedsDisplay]; +#else + [self setNeedsDisplay:YES]; +#endif +} + - (CGFloat)usedWidth { if (!_layoutManager) { @@ -190,6 +255,14 @@ - (CGFloat)usedWidth return ceil([_layoutManager usedRectForTextContainer:_textContainer].size.width); } +- (CGFloat)usedHeight +{ + if (!_layoutManager) { + return 0; + } + return ceil([_layoutManager usedRectForTextContainer:_textContainer].size.height); +} + - (void)drawRect:(CGRect)rect { if (!_layoutManager) { @@ -221,9 +294,13 @@ @implementation ENRMCodeBlockContainerView { CGFloat _headerLabelLineHeight; NSAttributedString *_languageLabel; CGSize _languageLabelSize; + // Wrapped code height, valid for _wrappedHeightWidth until the code changes. + CGFloat _wrappedHeight; + CGFloat _wrappedHeightWidth; #if !TARGET_OS_OSX UIFont *_headerFont; UIButton *_copyButton; + UIButton *_wrapButton; UITapGestureRecognizer *_tapRecognizer; #else NSFont *_headerFont; @@ -260,6 +337,7 @@ - (instancetype)initWithConfig:(StyleConfig *)config _config = config; _cachedCode = @""; _fenceChar = @"`"; + _wrappedHeightWidth = -1; _dynamicProps = [[ENRMDynamicBlockProps alloc] init]; _headerFont = ENRMCodeBlockHeaderFont(config); _headerLabelLineHeight = ENRMCodeBlockHeaderLabelLineHeight(_headerFont); @@ -274,9 +352,7 @@ - (instancetype)initWithConfig:(StyleConfig *)config [self addInteraction:contextMenu]; _copyButton = [UIButton buttonWithType:UIButtonTypeSystem]; - UIImageSymbolConfiguration *symbolConfig = - [UIImageSymbolConfiguration configurationWithPointSize:[config codeBlockFont].pointSize * kENRMHeaderIconScale - weight:UIImageSymbolWeightRegular]; + UIImageSymbolConfiguration *symbolConfig = ENRMCodeBlockHeaderSymbolConfiguration(config); [_copyButton setImage:[UIImage systemImageNamed:@"doc.on.doc" withConfiguration:symbolConfig] forState:UIControlStateNormal]; _copyButton.tintColor = [[config codeBlockColor] colorWithAlphaComponent:kENRMHeaderSecondaryAlpha]; @@ -340,12 +416,17 @@ - (void)layoutScrollArea _scrollView.frame = frame; CGFloat horizontalPad = MAX(inset - borderW, 0); - CGFloat contentWidth = MAX(_codeSize.width + horizontalPad * 2, frame.size.width); + _codeContentView.wrapWidth = _wrapped ? MAX(ENRMCodeBlockWrapWidth(self.bounds.size.width, _config), 1) : 0; + CGFloat codeWidth = _wrapped ? 0 : _codeSize.width; + CGFloat contentWidth = MAX(codeWidth + horizontalPad * 2, frame.size.width); _codeContentView.textOrigin = CGPointMake(horizontalPad, inset); _codeContentView.frame = CGRectMake(0, 0, contentWidth, frame.size.height); #if !TARGET_OS_OSX _scrollView.contentSize = CGSizeMake(contentWidth, frame.size.height); _scrollView.scrollEnabled = contentWidth > frame.size.width; + if (_wrapped) { + _scrollView.contentOffset = CGPointZero; + } #endif } @@ -372,6 +453,74 @@ - (void)layoutHeaderButton CGFloat buttonLeft = MAX(self.bounds.size.width - [self contentInset] - headerH + iconSlack, 0); CGFloat buttonTop = MAX([self headerCenterY] - headerH / 2, 0); _copyButton.frame = CGRectMake(buttonLeft, buttonTop, headerH, headerH); +#if !TARGET_OS_OSX + // The wrap toggle sits one button-width left of copy, in the same row. + _wrapButton.frame = CGRectMake(MAX(buttonLeft - headerH, 0), buttonTop, headerH, headerH); +#endif +} + +#if !TARGET_OS_OSX +- (void)setShowsWrapButton:(BOOL)showsWrapButton +{ + if (_showsWrapButton == showsWrapButton) { + return; + } + _showsWrapButton = showsWrapButton; + if (showsWrapButton && !_wrapButton) { + _wrapButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_wrapButton setImage:[UIImage systemImageNamed:@"text.alignleft" + withConfiguration:ENRMCodeBlockHeaderSymbolConfiguration(_config)] + forState:UIControlStateNormal]; + [_wrapButton addTarget:self action:@selector(toggleWrap) forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:_wrapButton]; + [self updateWrapButtonAppearance]; + } + _wrapButton.hidden = !showsWrapButton; + [self setNeedsLayout]; +} + +- (void)updateWrapButtonAppearance +{ + // Full-strength tint marks the toggle as on; off matches the copy button. + CGFloat alpha = _wrapped ? 1 : kENRMHeaderSecondaryAlpha; + _wrapButton.tintColor = [[_config codeBlockColor] colorWithAlphaComponent:alpha]; + _wrapButton.accessibilityLabel = [self wrapActionLabel]; +} +#endif + +- (NSString *)wrapActionLabel +{ + return _wrapped ? kENRMDisableLineWrapLabel : kENRMWrapLinesLabel; +} + +- (void)setWrapped:(BOOL)wrapped +{ + if (_wrapped == wrapped) { + return; + } + _wrapped = wrapped; + if (!wrapped) { + // The scroll extent reads the unwrapped width, which a rebuild while wrapped + // measured at the wrap width. + _codeContentView.wrapWidth = 0; + _codeSize.width = _codeContentView.usedWidth; + } +#if !TARGET_OS_OSX + [self updateWrapButtonAppearance]; + [self setNeedsLayout]; + [_codeContentView setNeedsDisplay]; +#else + self.needsLayout = YES; + [_codeContentView setNeedsDisplay:YES]; +#endif +} + +- (void)toggleWrap +{ + self.wrapped = !_wrapped; + if (self.onWrapToggle) { + self.onWrapToggle(self); + } } #if !TARGET_OS_OSX @@ -414,6 +563,7 @@ - (void)rebuildAttributedCode // width then reads out of that layout, and height stays analytic. _codeContentView.attributedCode = _attributedCode; _codeSize = CGSizeMake(_codeContentView.usedWidth, ENRMCodeBlockCodeHeight(_cachedCode, _config)); + _wrappedHeightWidth = -1; } - (void)applyCodeBlockNode:(MarkdownASTNode *)node @@ -479,7 +629,16 @@ - (CGFloat)contentInset - (CGFloat)measureHeight:(CGFloat)maxWidth { CGFloat inset = [self contentInset]; - return _codeSize.height + inset * 2 + [self headerHeight]; + CGFloat codeHeight = _codeSize.height; + if (_wrapped) { + if (_wrappedHeightWidth != maxWidth) { + _wrappedHeight = ENRMCodeBlockWrappedCodeHeight([self plainAttributedCode], + MAX(ENRMCodeBlockWrapWidth(maxWidth, _config), 1), _config); + _wrappedHeightWidth = maxWidth; + } + codeHeight = _wrappedHeight; + } + return codeHeight + inset * 2 + [self headerHeight]; } + (CGFloat)measureHeightForCodeBlockNode:(MarkdownASTNode *)node config:(StyleConfig *)config @@ -490,6 +649,17 @@ + (CGFloat)measureHeightForCodeBlockNode:(MarkdownASTNode *)node config:(StyleCo return codeHeight + inset * 2 + headerH; } ++ (CGFloat)measureWrappedHeightForCodeBlockNode:(MarkdownASTNode *)node + config:(StyleConfig *)config + maxWidth:(CGFloat)maxWidth +{ + CGFloat inset = ENRMCodeBlockContentInset(config); + CGFloat headerH = inset + ENRMCodeBlockHeaderLabelLineHeight(ENRMCodeBlockHeaderFont(config)); + NSAttributedString *code = ENRMCodeBlockPlainAttributedCode(ENRMCodeBlockExtractCode(node), config); + CGFloat codeHeight = ENRMCodeBlockWrappedCodeHeight(code, MAX(ENRMCodeBlockWrapWidth(maxWidth, config), 1), config); + return codeHeight + inset * 2 + headerH; +} + - (void)drawRect:(CGRect)rect { CGFloat borderWidth = [_config codeBlockBorderWidth]; @@ -576,9 +746,9 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { - // Let the copy button handle its own tap. + // Let the header buttons handle their own taps. for (UIView *hit = touch.view; hit != nil; hit = hit.superview) { - if (hit == _copyButton) { + if (hit == _copyButton || (_wrapButton != nil && hit == _wrapButton)) { return NO; } } @@ -643,18 +813,22 @@ - (UIAccessibilityTraits)accessibilityTraits return UIAccessibilityTraitStaticText; } -// The container is a single accessibility element, which hides the copy -// button subview from VoiceOver; expose the copy action explicitly instead. +// The container is a single accessibility element, which hides the header +// button subviews from VoiceOver; expose their actions explicitly instead. - (NSArray *)accessibilityCustomActions { - if (_pending || self.dynamicProps.menuCopyLabel.length == 0) { - return @[]; + NSMutableArray *actions = [NSMutableArray array]; + if (!_pending && self.dynamicProps.menuCopyLabel.length > 0) { + [actions addObject:[[UIAccessibilityCustomAction alloc] initWithName:self.dynamicProps.menuCopyLabel + target:self + selector:@selector(performCopyAccessibilityAction:)]]; } - UIAccessibilityCustomAction *copyAction = - [[UIAccessibilityCustomAction alloc] initWithName:self.dynamicProps.menuCopyLabel - target:self - selector:@selector(performCopyAccessibilityAction:)]; - return @[ copyAction ]; + if (_showsWrapButton) { + [actions addObject:[[UIAccessibilityCustomAction alloc] initWithName:[self wrapActionLabel] + target:self + selector:@selector(performWrapAccessibilityAction:)]]; + } + return actions; } - (BOOL)performCopyAccessibilityAction:(UIAccessibilityCustomAction *)action @@ -662,6 +836,12 @@ - (BOOL)performCopyAccessibilityAction:(UIAccessibilityCustomAction *)action [self copyCodeToPasteboard]; return YES; } + +- (BOOL)performWrapAccessibilityAction:(UIAccessibilityCustomAction *)action +{ + [self toggleWrap]; + return YES; +} #endif @end diff --git a/packages/react-native-enriched-markdown/package.json b/packages/react-native-enriched-markdown/package.json index 9dae0dea3..6c7ad2168 100644 --- a/packages/react-native-enriched-markdown/package.json +++ b/packages/react-native-enriched-markdown/package.json @@ -1,6 +1,6 @@ { "name": "react-native-enriched-markdown", - "version": "1.2.0-t3.11", + "version": "1.2.0-t3.12", "description": "Markdown-based Rich Text solution for React Native", "main": "./lib/module/index", "module": "./lib/module/index", diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index 257f5d6a7..380d1b293 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -597,6 +597,12 @@ export interface NativeProps extends ViewProps { * @default false */ enableCodeBlockPress?: CodegenTypes.WithDefault; + /** + * Shows a per-block line wrap toggle next to the copy button in fenced code + * block headers. Each block starts unwrapped. + * @default false + */ + enableCodeBlockWrapToggle?: CodegenTypes.WithDefault; /** * Callback fired when a math expression cannot be parsed or rendered by the * LaTeX engine. Receives the raw LaTeX `source` of the failing inline span or diff --git a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx index 4d92f7abf..5d602fe08 100644 --- a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx @@ -151,6 +151,7 @@ export const EnrichedMarkdownText = ({ onCopyPress, onLatexError, onCodeBlockPress, + enableCodeBlockWrapToggle = false, enableBlockContextMenu = true, enableLinkPreview, selectable = true, @@ -489,6 +490,7 @@ export const EnrichedMarkdownText = ({ @@ -500,6 +502,7 @@ export const EnrichedMarkdownText = ({ ); } diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index 73233a3ea..438856b45 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -299,6 +299,17 @@ export interface EnrichedMarkdownTextProps extends Omit { * @platform ios, android, web */ onCodeBlockPress?: (event: CodeBlockPressEvent) => void; + /** + * Shows a line wrap toggle next to the copy button in each fenced code block + * header. Every block starts unwrapped (horizontal scroll) and toggles on its + * own; the choice survives streaming updates but not a remount. Code blocks + * inside blockquotes keep scrolling. + * + * Only applies when `flavor="github"`. + * @default false + * @platform ios, android + */ + enableCodeBlockWrapToggle?: boolean; /** * Controls the long-press context menu on block views (code blocks, tables, * and block math). When false, long-pressing a block does not open the copy diff --git a/packages/react-native-enriched-markdown/src/web/nativeProps.ts b/packages/react-native-enriched-markdown/src/web/nativeProps.ts index 0ffb2c7ed..111128d58 100644 --- a/packages/react-native-enriched-markdown/src/web/nativeProps.ts +++ b/packages/react-native-enriched-markdown/src/web/nativeProps.ts @@ -37,6 +37,7 @@ export const NATIVE_ONLY_PROP_NAMES: Record = { onCopyPress: true, onLatexError: true, enableBlockContextMenu: true, + enableCodeBlockWrapToggle: true, enableLinkPreview: true, selectionHandleColor: true, allowFontScaling: true, From a481c7702b05f0eed534185b05ac0b460eb8a491 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 15:37:58 -0700 Subject: [PATCH 2/8] feat(react-native): localize the code block wrap toggle labels The wrap toggle announced hardcoded English on both platforms while the copy button next to it already read its label from props. Add a `codeBlock.wrap` / `codeBlock.unwrap` group to `accessibilityLabels` (English defaults "Wrap lines" / "Disable line wrap") and route it to the code block view the same way the other label groups reach their segment views. Co-Authored-By: Claude Fable 5.1 --- docs-md/ACCESSIBILITY.md | 2 ++ .../enriched/markdown/EnrichedMarkdown.kt | 3 +++ .../accessibility/AccessibilityLabels.kt | 2 ++ .../segments/CodeBlockContainerView.kt | 14 +++++++++---- .../markdown/segments/SegmentViewCreators.kt | 1 + .../utils/common/MarkdownViewManagerUtils.kt | 3 +++ .../ios/EnrichedMarkdown.mm | 7 +++++++ .../ios/EnrichedMarkdownText.mm | 4 ++++ .../ios/segments/ENRMCodeBlockContainerView.h | 5 +++++ .../ios/segments/ENRMCodeBlockContainerView.m | 18 ++++++++++++---- .../ios/utils/ENRMAccessibilityLabels.h | 4 ++++ .../src/EnrichedMarkdownNativeComponent.ts | 5 +++++ .../EnrichedMarkdownTextNativeComponent.ts | 5 +++++ .../src/accessibilityLabelDefaults.ts | 11 ++++++++++ .../src/types/AccessibilityLabels.ts | 21 +++++++++++++++++++ 15 files changed, 97 insertions(+), 8 deletions(-) diff --git a/docs-md/ACCESSIBILITY.md b/docs-md/ACCESSIBILITY.md index 3591c49a0..542505221 100644 --- a/docs-md/ACCESSIBILITY.md +++ b/docs-md/ACCESSIBILITY.md @@ -55,6 +55,8 @@ Every field is optional. Any field you omit falls back to the English default be | `blockquote.nestedQuote` | `"Nested blockquote"` | iOS + Android | | `table.row` | `"Row {n}: {content}"` | iOS + Android | | `math.equation` | `"Math: {latex}"` | iOS + Android | +| `codeBlock.wrap` | `"Wrap lines"` | iOS + Android | +| `codeBlock.unwrap` | `"Disable line wrap"` | iOS + Android | | `rotor.headings` | `"Headings"` | iOS only | | `rotor.links` | `"Links"` | iOS only | | `rotor.images` | `"Images"` | iOS only | diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index 5e772331d..279e741f9 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -505,6 +505,9 @@ class EnrichedMarkdown( segmentViews.filterIsInstance().forEach { it.accessibilityLabels = labels } + segmentViews.filterIsInstance().forEach { + it.accessibilityLabels = labels + } mathContainerClass?.let { mathClass -> segmentViews.filter { mathClass.isInstance(it) }.forEach { view -> runCatching { diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/accessibility/AccessibilityLabels.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/accessibility/AccessibilityLabels.kt index 9091d349a..fba8adeed 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/accessibility/AccessibilityLabels.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/accessibility/AccessibilityLabels.kt @@ -9,6 +9,8 @@ data class AccessibilityLabels( val nestedBlockquote: String = "", val tableRow: String = "", val mathEquation: String = "", + val codeBlockWrap: String = "", + val codeBlockUnwrap: String = "", val rotorHeadings: String = "", val rotorLinks: String = "", val rotorImages: String = "", diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt index 28403e346..932a44a6e 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt @@ -27,6 +27,7 @@ import android.widget.ImageView import androidx.appcompat.widget.AppCompatImageButton import androidx.appcompat.widget.AppCompatTextView import androidx.core.graphics.PathParser +import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.LineHeightSpan import com.swmansion.enriched.markdown.styles.CodeBlockStyle @@ -72,6 +73,14 @@ class CodeBlockContainerView( // created later see the same current values. See DynamicBlockProps. var dynamicProps: DynamicBlockProps = DynamicBlockProps() + // Wrap toggle labels (see AccessibilityLabels); the copy label lives on dynamicProps. + var accessibilityLabels: AccessibilityLabels = AccessibilityLabels() + set(value) { + if (field == value) return + field = value + updateWrapButtonAppearance() + } + private var code: String = "" private var language: String? = null private var fenceChar: String = "`" @@ -223,7 +232,7 @@ class CodeBlockContainerView( private fun updateWrapButtonAppearance() { // Full-strength tint marks the toggle as on; off matches the copy button. wrapIcon.setColor(if (wrapped) codeBlockStyle.color else secondaryColor(codeBlockStyle.color)) - wrapButton.contentDescription = if (wrapped) DISABLE_LINE_WRAP_LABEL else WRAP_LINES_LABEL + wrapButton.contentDescription = if (wrapped) accessibilityLabels.codeBlockUnwrap else accessibilityLabels.codeBlockWrap } private fun toggleWrap() { @@ -450,9 +459,6 @@ class CodeBlockContainerView( } companion object { - private const val WRAP_LINES_LABEL = "Wrap lines" - private const val DISABLE_LINE_WRAP_LABEL = "Disable line wrap" - private val WRAP_TEXT_PATH: Path by lazy { PathParser.createPathFromPathData( "M4 19h6v-2H4v2zM20 5H4v2h16V5zm-3 6H4v2h13.25c1.1 0 2 .9 2 2s-.9 2-2 2H15v-2l-3 3 3 3v-2h2c2.21 0 4-1.79 4-4s-1.79-4-4-4z", diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt index 4dcaf4d1c..f677c50fd 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt @@ -146,6 +146,7 @@ object SegmentViewCreators { config: SegmentViewConfig, ) = CodeBlockContainerView(config.context, config.style).apply { dynamicProps = config.dynamicProps + accessibilityLabels = config.accessibilityLabels applyCodeBlockNode(segment.node) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index b4d3cdc5f..40ef07bff 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -181,6 +181,7 @@ fun parseAccessibilityLabels(value: ReadableMap?): AccessibilityLabels { val blockquote = value.getMap("blockquote") val table = value.getMap("table") val math = value.getMap("math") + val codeBlock = value.getMap("codeBlock") val rotor = value.getMap("rotor") return AccessibilityLabels( bulletPoint = list?.getString("bulletPoint") ?: "", @@ -191,6 +192,8 @@ fun parseAccessibilityLabels(value: ReadableMap?): AccessibilityLabels { nestedBlockquote = blockquote?.getString("nestedQuote") ?: "", tableRow = table?.getString("row") ?: "", mathEquation = math?.getString("equation") ?: "", + codeBlockWrap = codeBlock?.getString("wrap") ?: "", + codeBlockUnwrap = codeBlock?.getString("unwrap") ?: "", rotorHeadings = rotor?.getString("headings") ?: "", rotorLinks = rotor?.getString("links") ?: "", rotorImages = rotor?.getString("images") ?: "", diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index 54f187ec2..340914855 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -766,6 +766,8 @@ - (void)pushAccessibilityLabelsToSegments ((TableContainerView *)segment).accessibilityLabels = _accessibilityLabels; } else if ([segment isKindOfClass:[ENRMBlockquoteContainerView class]]) { ((ENRMBlockquoteContainerView *)segment).accessibilityLabels = _accessibilityLabels; + } else if ([segment isKindOfClass:[ENRMCodeBlockContainerView class]]) { + ((ENRMCodeBlockContainerView *)segment).accessibilityLabels = _accessibilityLabels; } #if ENRICHED_MARKDOWN_MATH else if ([segment isKindOfClass:[ENRMMathContainerView class]]) { @@ -1146,6 +1148,7 @@ - (ENRMCodeBlockContainerView *)createCodeBlockViewForSegment:(ENRMCodeBlockSegm { ENRMCodeBlockContainerView *codeBlockView = [[ENRMCodeBlockContainerView alloc] initWithConfig:_config]; codeBlockView.dynamicProps = _dynamicBlockProps; + codeBlockView.accessibilityLabels = _accessibilityLabels; __weak EnrichedMarkdown *weakSelf = self; codeBlockView.onCopyPress = ^(NSString *code, NSString *language) { @@ -1471,6 +1474,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.table.row.c_str()]; _accessibilityLabels.mathEquation = [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.math.equation.c_str()]; + _accessibilityLabels.codeBlockWrap = + [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.codeBlock.wrap.c_str()]; + _accessibilityLabels.codeBlockUnwrap = + [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.codeBlock.unwrap.c_str()]; _accessibilityLabels.rotorHeadings = [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.rotor.headings.c_str()]; _accessibilityLabels.rotorLinks = diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm index 43b3a3796..331b2bcd6 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm @@ -768,6 +768,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.table.row.c_str()]; _accessibilityLabels.mathEquation = [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.math.equation.c_str()]; + _accessibilityLabels.codeBlockWrap = + [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.codeBlock.wrap.c_str()]; + _accessibilityLabels.codeBlockUnwrap = + [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.codeBlock.unwrap.c_str()]; _accessibilityLabels.rotorHeadings = [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.rotor.headings.c_str()]; _accessibilityLabels.rotorLinks = diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h index b383d0e86..e84d65c52 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h @@ -4,6 +4,7 @@ #import "StyleConfig.h" @class MarkdownASTNode; +@class ENRMAccessibilityLabels; @class ENRMCodeBlockContainerView; NS_ASSUME_NONNULL_BEGIN @@ -51,6 +52,10 @@ typedef void (^ENRMCodeBlockWrapToggleBlock)(ENRMCodeBlockContainerView *view); // block added after a toggle is born current. See ENRMDynamicBlockProps. @property (nonatomic, strong) ENRMDynamicBlockProps *dynamicProps; +// Wrap toggle labels (see ENRMAccessibilityLabels); the copy label lives on +// dynamicProps. +@property (nonatomic, strong, nullable) ENRMAccessibilityLabels *accessibilityLabels; + // Fired when the code is copied (header button, context-menu Copy, or the // VoiceOver copy action); set by the host to bridge up to the JS onCopyPress // event. Not fired for "Copy as Markdown". diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m index 9777a5bd7..9c0cdff34 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m @@ -1,4 +1,5 @@ #import "ENRMCodeBlockContainerView.h" +#import "ENRMAccessibilityLabels.h" #import "ENRMCodeBlockContent.h" #import "ENRMCodeBlockHighlighter.h" #import "ENRMHorizontalScrollView.h" @@ -28,8 +29,6 @@ static const CGFloat kENRMHeaderIconScale = 0.72; static const CGFloat kENRMHeaderSecondaryAlpha = 0.6; static const CGFloat kENRMHeaderDividerAlpha = 0.2; -static NSString *const kENRMWrapLinesLabel = @"Wrap lines"; -static NSString *const kENRMDisableLineWrapLabel = @"Disable line wrap"; // Height geometry and code-run construction shared by the instance layout path // and the view-free measureHeightForCodeBlockNode:, so the two cannot drift. @@ -490,7 +489,18 @@ - (void)updateWrapButtonAppearance - (NSString *)wrapActionLabel { - return _wrapped ? kENRMDisableLineWrapLabel : kENRMWrapLinesLabel; + return _wrapped ? _accessibilityLabels.codeBlockUnwrap : _accessibilityLabels.codeBlockWrap; +} + +- (void)setAccessibilityLabels:(ENRMAccessibilityLabels *)labels +{ + if (_accessibilityLabels == labels) { + return; + } + _accessibilityLabels = labels; +#if !TARGET_OS_OSX + [self updateWrapButtonAppearance]; +#endif } - (void)setWrapped:(BOOL)wrapped @@ -823,7 +833,7 @@ - (UIAccessibilityTraits)accessibilityTraits target:self selector:@selector(performCopyAccessibilityAction:)]]; } - if (_showsWrapButton) { + if (_showsWrapButton && [self wrapActionLabel].length > 0) { [actions addObject:[[UIAccessibilityCustomAction alloc] initWithName:[self wrapActionLabel] target:self selector:@selector(performWrapAccessibilityAction:)]]; diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMAccessibilityLabels.h b/packages/react-native-enriched-markdown/ios/utils/ENRMAccessibilityLabels.h index 9dbeefb2e..4ddfd6084 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMAccessibilityLabels.h +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMAccessibilityLabels.h @@ -12,6 +12,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy) NSString *nestedBlockquote; @property (nonatomic, copy) NSString *tableRow; @property (nonatomic, copy) NSString *mathEquation; +@property (nonatomic, copy) NSString *codeBlockWrap; +@property (nonatomic, copy) NSString *codeBlockUnwrap; @property (nonatomic, copy) NSString *rotorHeadings; @property (nonatomic, copy) NSString *rotorLinks; @property (nonatomic, copy) NSString *rotorImages; @@ -30,6 +32,8 @@ template static bool ENRMAccessibilityLabelsChanged(const T &oldLab oldLabels.blockquote.quote != newLabels.blockquote.quote || oldLabels.blockquote.nestedQuote != newLabels.blockquote.nestedQuote || oldLabels.table.row != newLabels.table.row || oldLabels.math.equation != newLabels.math.equation || + oldLabels.codeBlock.wrap != newLabels.codeBlock.wrap || + oldLabels.codeBlock.unwrap != newLabels.codeBlock.unwrap || oldLabels.rotor.headings != newLabels.rotor.headings || oldLabels.rotor.links != newLabels.rotor.links || oldLabels.rotor.images != newLabels.rotor.images; } diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index 380d1b293..4b9346ffe 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -360,6 +360,10 @@ export interface AccessibilityLabelsTableProps { export interface AccessibilityLabelsMathProps { equation: string; } +export interface AccessibilityLabelsCodeBlockProps { + wrap: string; + unwrap: string; +} export interface AccessibilityLabelsRotorProps { headings: string; links: string; @@ -370,6 +374,7 @@ export interface AccessibilityLabelsProps { blockquote: Readonly; table: Readonly; math: Readonly; + codeBlock: Readonly; rotor: Readonly; } diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts index 001c380e8..1a7590886 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts @@ -361,6 +361,10 @@ export interface AccessibilityLabelsTableProps { export interface AccessibilityLabelsMathProps { equation: string; } +export interface AccessibilityLabelsCodeBlockProps { + wrap: string; + unwrap: string; +} export interface AccessibilityLabelsRotorProps { headings: string; links: string; @@ -371,6 +375,7 @@ export interface AccessibilityLabelsProps { blockquote: Readonly; table: Readonly; math: Readonly; + codeBlock: Readonly; rotor: Readonly; } diff --git a/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts b/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts index bdb4857a9..04884ba08 100644 --- a/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts +++ b/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts @@ -25,6 +25,10 @@ export const DEFAULT_ACCESSIBILITY_LABELS: ResolvedAccessibilityLabels = { math: { equation: 'Math: {latex}', }, + codeBlock: { + wrap: 'Wrap lines', + unwrap: 'Disable line wrap', + }, rotor: { headings: 'Headings', links: 'Links', @@ -72,6 +76,13 @@ export function resolveAccessibilityLabels( equation: labels?.math?.equation ?? DEFAULT_ACCESSIBILITY_LABELS.math.equation, }, + codeBlock: { + wrap: + labels?.codeBlock?.wrap ?? DEFAULT_ACCESSIBILITY_LABELS.codeBlock.wrap, + unwrap: + labels?.codeBlock?.unwrap ?? + DEFAULT_ACCESSIBILITY_LABELS.codeBlock.unwrap, + }, rotor: { headings: labels?.rotor?.headings ?? DEFAULT_ACCESSIBILITY_LABELS.rotor.headings, diff --git a/packages/react-native-enriched-markdown/src/types/AccessibilityLabels.ts b/packages/react-native-enriched-markdown/src/types/AccessibilityLabels.ts index a53a694b0..383ab11a1 100644 --- a/packages/react-native-enriched-markdown/src/types/AccessibilityLabels.ts +++ b/packages/react-native-enriched-markdown/src/types/AccessibilityLabels.ts @@ -74,6 +74,23 @@ export interface AccessibilityLabels { equation?: string; }; + /** + * Labels for the fenced code block header's line wrap toggle + * (`enableCodeBlockWrapToggle`). iOS + Android. + */ + codeBlock?: { + /** + * Spoken while the block scrolls horizontally. + * @default 'Wrap lines' + */ + wrap?: string; + /** + * Spoken while the block's lines are wrapped. + * @default 'Disable line wrap' + */ + unwrap?: string; + }; + /** * Labels for the VoiceOver rotor (the secondary jump-by-type navigator on iOS). * iOS only — Android has no rotor concept. @@ -110,6 +127,10 @@ export interface ResolvedAccessibilityLabels { math: { equation: string; }; + codeBlock: { + wrap: string; + unwrap: string; + }; rotor: { headings: string; links: string; From 1c6f65ed52d866b476d58e385549d3b06a7e289b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 15:38:17 -0700 Subject: [PATCH 3/8] fix(react-native): reset code block wrap state when the markdown is replaced Wrap state is keyed by a code block's position among the top-level code blocks, so it only stays meaningful while the document grows in place. A mounted view that received unrelated markdown (a recycled list cell, an edited message, a block inserted above) kept the old indexes and wrapped the wrong block. Clear the set, and the measure pass's copy of it, when the new markdown does not start with the previous one, and force a re-measure the way the toggle itself does. Android keeps the rule in `keepsCodeBlockWrapState` so it can be unit tested without the JNI parser. Co-Authored-By: Claude Fable 5.1 --- .../swmansion/enriched/markdown/EnrichedMarkdown.kt | 12 ++++++++++++ .../ios/EnrichedMarkdown.mm | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index 279e741f9..31ed95f59 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -53,6 +53,14 @@ import java.util.EnumSet import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +// Code block wrap state is keyed by block position, so it only stays meaningful +// while the document grows in place (streaming appends). Anything else - a +// recycled cell, an edited message, a block inserted above - starts unwrapped. +internal fun keepsCodeBlockWrapState( + previous: String, + next: String, +): Boolean = next.startsWith(previous) + class EnrichedMarkdown( context: Context, ) : ContainerNodeView(context), @@ -289,6 +297,10 @@ class EnrichedMarkdown( fun setMarkdownContent(markdown: String) { if (currentMarkdown == markdown) return + if (wrappedCodeBlocks.isNotEmpty() && !keepsCodeBlockWrapState(currentMarkdown, markdown)) { + setWrappedCodeBlocks(emptySet()) + dirtyFlags += DirtyFlag.FORCE_HEIGHT + } currentMarkdown = markdown ++currentRenderId renderPending = true diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index 340914855..bef7b0630 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -1260,6 +1260,13 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & BOOL markdownChanged = oldViewProps.markdown != newViewProps.markdown; if (markdownChanged) { _dirtyFlags |= ENRMDirtyRender; + // Wrap state is keyed by block position, so it only stays meaningful while + // the document grows in place (streaming appends). Anything else - a recycled + // cell, an edited message, a block inserted above - starts unwrapped. + if (self.wrappedCodeBlockIndices.count > 0 && newViewProps.markdown.rfind(oldViewProps.markdown, 0) != 0) { + self.wrappedCodeBlockIndices = [NSSet set]; + _dirtyFlags |= ENRMDirtyForceHeight; + } } NSDictionary *mediaOverrides = ENRMMediaOverridesFromProps(newViewProps); From bd197d9a3aa87d67d78abeb093b3c1797a82ea34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 15:38:18 -0700 Subject: [PATCH 4/8] perf(ios): key the measurement cache by wrapped code blocks instead of skipping it Any wrapped block disabled the props-keyed measurement cache for the whole document, so every Yoga pass re-measured from scratch. Fold an order-independent fingerprint of the wrapped index set into the cache key, as Android folds the sorted list into its props hash, and drop the bypass flag. The set stays an immutable NSSet read through the atomic property, so the measure thread never sees a partial update. Co-Authored-By: Claude Fable 5.1 --- .../ios/internals/EnrichedMarkdownShadowNode.mm | 15 +++++++++++++-- .../ios/internals/MeasurementCache.h | 17 +++++++++++------ .../ios/internals/ShadowMeasurementUtils.h | 14 +++++++------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm index 7d94b0d84..3405c6ea5 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm @@ -50,6 +50,17 @@ } } +// Order-independent fingerprint of the wrapped set for the measurement cache +// key: 0 for none, and {0} hashes apart from {} (HashUtils::combine is not 0 at 0). +static size_t ENRMWrappedCodeBlocksFingerprint(NSSet *wrappedCodeBlocks) +{ + size_t h = 0; + for (NSNumber *index in [wrappedCodeBlocks.allObjects sortedArrayUsingSelector:@selector(compare:)]) { + HashUtils::hash_one(h, index.unsignedLongValue); + } + return h; +} + Size EnrichedMarkdownShadowNode::measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const { @@ -62,7 +73,7 @@ ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight; - // Wrapped code blocks are view state, not props, so they bypass the props-keyed cache. + // Wrapped code blocks are view state, not props, so they join the cache key by fingerprint. const auto componentViewRef = getStateData().getComponentViewRef(); RCTInternalGenericWeakWrapper *weakWrapper = (RCTInternalGenericWeakWrapper *)unwrapManagedObject(componentViewRef); EnrichedMarkdown *view = weakWrapper ? (EnrichedMarkdown *)weakWrapper.object : nil; @@ -75,7 +86,7 @@ return ENRMMeasureSegmentedMarkdownViewFree(*props, maxWidth, fontScale, pointScaleFactor, resolvedLayoutDirection, wrappedCodeBlocks); }, - wrappedCodeBlocks.count == 0); + ENRMWrappedCodeBlocksFingerprint(wrappedCodeBlocks)); } } // namespace facebook::react diff --git a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h index c69302c7c..dbd7e959c 100644 --- a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h +++ b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h @@ -47,6 +47,8 @@ struct MeasurementCacheKey { bool md4cFlagsAdmonitions; size_t styleFingerprint; size_t linkPillContentFingerprint; + // Code blocks the user wrapped (view state, not props); 0 when none. + size_t wrappedCodeBlocksFingerprint; CGFloat fontScale; MarkdownFlavor flavor; std::string lineBreakStrategyIOS; @@ -61,15 +63,16 @@ struct MeasurementCacheKey { return std::tie(markdown, maxWidth, allowTrailingMargin, allowFontScaling, maxFontSizeMultiplier, md4cFlagsUnderline, md4cFlagsSuperscript, md4cFlagsSubscript, md4cFlagsHighlight, md4cFlagsLatexMath, md4cFlagsHardSoftBreaks, md4cFlagsPreserveBlankLines, md4cFlagsAdmonitions, - styleFingerprint, linkPillContentFingerprint, fontScale, flavor, lineBreakStrategyIOS, - writingDirection, numberOfLines, ellipsizeMode, linkRegexKey, inlineCodeLinkRegexKey) == + styleFingerprint, linkPillContentFingerprint, wrappedCodeBlocksFingerprint, fontScale, flavor, + lineBreakStrategyIOS, writingDirection, numberOfLines, ellipsizeMode, linkRegexKey, + inlineCodeLinkRegexKey) == std::tie(other.markdown, other.maxWidth, other.allowTrailingMargin, other.allowFontScaling, other.maxFontSizeMultiplier, other.md4cFlagsUnderline, other.md4cFlagsSuperscript, other.md4cFlagsSubscript, other.md4cFlagsHighlight, other.md4cFlagsLatexMath, other.md4cFlagsHardSoftBreaks, other.md4cFlagsPreserveBlankLines, other.md4cFlagsAdmonitions, - other.styleFingerprint, other.linkPillContentFingerprint, other.fontScale, other.flavor, - other.lineBreakStrategyIOS, other.writingDirection, other.numberOfLines, other.ellipsizeMode, - other.linkRegexKey, other.inlineCodeLinkRegexKey); + other.styleFingerprint, other.linkPillContentFingerprint, other.wrappedCodeBlocksFingerprint, + other.fontScale, other.flavor, other.lineBreakStrategyIOS, other.writingDirection, + other.numberOfLines, other.ellipsizeMode, other.linkRegexKey, other.inlineCodeLinkRegexKey); } }; @@ -92,6 +95,7 @@ struct MeasurementCacheKeyHash { HashUtils::hash_one(h, key.md4cFlagsAdmonitions); HashUtils::hash_one(h, key.styleFingerprint); HashUtils::hash_one(h, key.linkPillContentFingerprint); + HashUtils::hash_one(h, key.wrappedCodeBlocksFingerprint); HashUtils::hash_one(h, key.fontScale); HashUtils::hash_one(h, static_cast(key.flavor)); HashUtils::hash_one(h, key.lineBreakStrategyIOS); @@ -196,7 +200,7 @@ template inline std::string buildLinkRecognitionCacheKey(c template inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFloat maxWidth, CGFloat fontScale, - MarkdownFlavor flavor) + MarkdownFlavor flavor, size_t wrappedCodeBlocksFingerprint = 0) { return MeasurementCacheKey{ .markdown = props.markdown, @@ -215,6 +219,7 @@ inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFl .styleFingerprint = computeStyleFingerprint(props.markdownStyle) ^ ENRMImageSourcesFingerprint(props) ^ ENRMMediaPropsFingerprint(props), .linkPillContentFingerprint = computeLinkPillContentFingerprint(props.linkPillContent), + .wrappedCodeBlocksFingerprint = wrappedCodeBlocksFingerprint, .fontScale = fontScale, .flavor = flavor, .lineBreakStrategyIOS = props.lineBreakStrategyIOS, diff --git a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h index 06b7b651a..523809baa 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h +++ b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h @@ -87,9 +87,9 @@ static inline bool ENRMPropsNeedExactStreamingMeasurement(const PropsT &oldProps * `ENRMMeasureSegmentedMarkdownViewFree`); the resolved view is still handed * to the block for strategies that can use it. `fontScale` handed to the * block is the real multiplier even when streaming skips the cache — - * rendering must always use it. `allowMeasurementCache` lets a caller skip the - * props-keyed cache when view state outside the props changes the size (code - * blocks the user wrapped). + * rendering must always use it. `wrappedCodeBlocksFingerprint` folds view + * state outside the props that changes the size (code blocks the user wrapped) + * into the cache key. */ template static inline Size @@ -97,7 +97,7 @@ ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr int &lastExactMeasurementCounter, CGSize &lastExactMeasurementSize, MarkdownFlavor flavor, const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints, CGSize (^measureUncached)(ViewT *view, CGFloat maxWidth, CGFloat fontScale), - bool allowMeasurementCache = true) + size_t wrappedCodeBlocksFingerprint = 0) { CGFloat maxWidth = layoutConstraints.maximumSize.width; @@ -119,11 +119,11 @@ ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr } } - const bool shouldUseMeasurementCache = !typedProps.streamingAnimation && allowMeasurementCache; + const bool shouldUseMeasurementCache = !typedProps.streamingAnimation; CGFloat fontScale = ENRMFontScaleForMeasurement(typedProps.allowFontScaling, layoutContext); if (shouldUseMeasurementCache && !typedProps.markdown.empty()) { - auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor); + auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor, wrappedCodeBlocksFingerprint); CachedSize cached; if (MeasurementCache::shared().get(cacheKey, cached)) { return ENRMClampMeasuredSize(CGSizeMake(cached.width, cached.height), layoutConstraints); @@ -133,7 +133,7 @@ ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr CGSize size = measureUncached(view, maxWidth, fontScale); if (shouldUseMeasurementCache && !typedProps.markdown.empty()) { - auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor); + auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor, wrappedCodeBlocksFingerprint); MeasurementCache::shared().set(cacheKey, {size.width, size.height}); } From c8ed3396b413a668a936dfa60cb4eb3cf5def5b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 15:38:20 -0700 Subject: [PATCH 5/8] refactor(ios): drop the unused usedHeight on the code block content view Co-Authored-By: Claude Fable 5.1 --- .../ios/segments/ENRMCodeBlockContainerView.m | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m index 9c0cdff34..69cd0af86 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m @@ -180,7 +180,6 @@ @interface ENRMCodeBlockContentView : RCTUIView // Laid-out content width, for the horizontal scroll extent. Free once the // cached stack exists; no separate measurement pass. @property (nonatomic, readonly) CGFloat usedWidth; -@property (nonatomic, readonly) CGFloat usedHeight; #if TARGET_OS_OSX @property (nonatomic, copy, nullable) NSMenu * (^menuProvider)(void); #endif @@ -254,14 +253,6 @@ - (CGFloat)usedWidth return ceil([_layoutManager usedRectForTextContainer:_textContainer].size.width); } -- (CGFloat)usedHeight -{ - if (!_layoutManager) { - return 0; - } - return ceil([_layoutManager usedRectForTextContainer:_textContainer].size.height); -} - - (void)drawRect:(CGRect)rect { if (!_layoutManager) { From 8d9dfc740102b3e4d499f159e4c956c4305b1809 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 15:38:21 -0700 Subject: [PATCH 6/8] docs: describe the code block wrap toggle's state, scope, and labels State belongs to the block's position and resets when the markdown is replaced rather than appended to; blocks inside blockquotes and admonitions have no toggle; the spoken labels come from `accessibilityLabels.codeBlock`. Add the prop to the website API reference, which the PR had skipped. Co-Authored-By: Claude Fable 5.1 --- docs-md/API_REFERENCE.md | 6 +++++- .../api-reference/enriched-markdown-text.md | 11 +++++++++++ docs/docs/user-experience/accessibility.md | 4 +++- .../src/types/MarkdownTextProps.ts | 6 ++++-- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md index 887ed437e..b21dc41af 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -284,7 +284,7 @@ Works in both flavors: `flavor="github"` fires on the container-based code block ### `enableCodeBlockWrapToggle` -Adds a line wrap toggle next to the copy button in each fenced code block header (`flavor="github"`). Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. Each block keeps its own state across streaming updates, but not across a remount. Code blocks inside blockquotes keep scrolling. VoiceOver and TalkBack announce it as "Wrap lines" / "Disable line wrap". +Adds a line wrap toggle next to the copy button in each fenced code block header (`flavor="github"`). Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. The state belongs to the block's position in the document: it survives streaming appends and resets when the markdown is replaced rather than appended to. Code blocks inside blockquotes and admonitions have no toggle and keep scrolling. The toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). | Type | Default Value | Platform | | --------- | ------------- | ------------ | @@ -790,6 +790,10 @@ interface AccessibilityLabels { math?: { equation?: string; // "Math: {latex}" }; + codeBlock?: { // enableCodeBlockWrapToggle + wrap?: string; // "Wrap lines" + unwrap?: string; // "Disable line wrap" + }; rotor?: { // iOS only headings?: string; // "Headings" links?: string; // "Links" diff --git a/docs/docs/react-native/api-reference/enriched-markdown-text.md b/docs/docs/react-native/api-reference/enriched-markdown-text.md index 0a84cc9df..767402bca 100644 --- a/docs/docs/react-native/api-reference/enriched-markdown-text.md +++ b/docs/docs/react-native/api-reference/enriched-markdown-text.md @@ -217,6 +217,12 @@ Controls the long-press context menu on block views - fenced code blocks, tables iOS, Android, and macOS only - the block context menu is a native interaction, and the web build renders blocks without one.} /> +### `enableCodeBlockWrapToggle` + +Adds a line wrap toggle next to the copy button in each fenced code block header. Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. The state belongs to the block's position in the document: it survives streaming appends and resets when the Markdown is replaced rather than appended to. Code blocks inside blockquotes and admonitions have no toggle and keep scrolling. Needs [`flavor="github"`](#flavor); the toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). iOS and Android only. + + + ### `enableLinkPreview` {#enablelinkpreview} Controls the native link preview on long press. It is on by default, and supplying [`onLinkLongPress`](#onlinklongpress) turns it off - your handler owns the long press instead. That override cannot be undone from this prop: with `onLinkLongPress` set, even an explicit `enableLinkPreview={true}` is discarded. Setting [`selectable={false}`](#selectable) also suppresses the preview. @@ -491,6 +497,11 @@ interface AccessibilityLabels { math?: { equation?: string; // {latex} is the equation source }; + codeBlock?: { + // the enableCodeBlockWrapToggle header button + wrap?: string; + unwrap?: string; + }; rotor?: { // iOS only, Android has no rotor headings?: string; diff --git a/docs/docs/user-experience/accessibility.md b/docs/docs/user-experience/accessibility.md index aa8e4db92..0b0a70a90 100644 --- a/docs/docs/user-experience/accessibility.md +++ b/docs/docs/user-experience/accessibility.md @@ -61,6 +61,8 @@ packages ship. */} | `blockquote.nestedQuote` | `"Nested blockquote"` | iOS + Android | | `table.row` | `"Row {n}: {content}"` | iOS + Android | | `math.equation` | `"Math: {latex}"` | iOS + Android | +| `codeBlock.wrap` | `"Wrap lines"` | iOS + Android | +| `codeBlock.unwrap` | `"Disable line wrap"` | iOS + Android | | `rotor.headings` / `rotor.links` / `rotor.images` | `"Headings"` / `"Links"` / `"Images"` | iOS only | Placeholders: `{n}` (1-based index), `{content}` (comma-joined cell texts), `{latex}` (equation source). Preserve placeholder names exactly in translations. Defaults use the no-plural cardinal form so a single template works in every language. @@ -117,7 +119,7 @@ Four sharp edges are worth knowing: - **Android** has no rotor concept, so `accessibilityLabels.rotor.*` is ignored there. - **Task list items** have no accessibility handling in the React Native package on either platform: a checkbox is read as plain list text, with no checked state announced and no toggle action exposed. There is no label key for it. - **Admonitions** carry no accessibility role; they are announced as the blockquote they are built from. -- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label, and iOS vends a VoiceOver copy action, but the block itself has no dedicated role. +- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label and the optional wrap toggle (`enableCodeBlockWrapToggle`) speaks `codeBlock.wrap` / `codeBlock.unwrap`; iOS vends both as VoiceOver actions, but the block itself has no dedicated role. - The platform tabs above describe the renderer. The **editor** speaks neither heading level nor task state (see [Editor accessibility](#editor-accessibility)). ## Reference diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index 438856b45..26060e2ec 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -302,8 +302,10 @@ export interface EnrichedMarkdownTextProps extends Omit { /** * Shows a line wrap toggle next to the copy button in each fenced code block * header. Every block starts unwrapped (horizontal scroll) and toggles on its - * own; the choice survives streaming updates but not a remount. Code blocks - * inside blockquotes keep scrolling. + * own. The state belongs to the block's position in the document: it survives + * streaming appends and resets when the markdown is replaced. Code blocks + * inside blockquotes and admonitions have no toggle and keep scrolling. The + * toggle's spoken labels come from `accessibilityLabels.codeBlock`. * * Only applies when `flavor="github"`. * @default false From 52db77b34872a743f4af149bbe7ac32cf6174960 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 15:48:05 -0700 Subject: [PATCH 7/8] test(android): cover code block wrap measurement and wrap state reset Checks that measureCodeBlockNodeHeight matches the laid-out CodeBlockContainerView height for wrapped and unwrapped blocks at several widths, and that keepsCodeBlockWrapState keeps wrap state only for in-place growth (streaming appends, identical markdown). Co-Authored-By: Claude Opus 5.5 --- .../segments/CodeBlockContainerViewTest.kt | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt new file mode 100644 index 000000000..3e60a2bae --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt @@ -0,0 +1,105 @@ +package com.swmansion.enriched.markdown.segments + +import android.graphics.Color +import android.view.View.MeasureSpec +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.swmansion.enriched.markdown.keepsCodeBlockWrapState +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.parser.MarkdownASTNode.NodeType +import com.swmansion.enriched.markdown.styles.StyleConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class CodeBlockContainerViewTest { + private val context = RuntimeEnvironment.getApplication() + + @Test + fun measuredHeightMatchesTheLaidOutViewWrappedAndUnwrapped() { + val config = styleConfig() + val line = "val values = listOf(" + (1..60).joinToString(", ") { "item$it" } + ")" + val node = + MarkdownASTNode( + NodeType.CodeBlock, + attributes = mapOf("fenceChar" to "`", "language" to "kotlin"), + children = listOf(MarkdownASTNode(NodeType.Text, "$line\n")), + ) + + for (width in listOf(240, 360, 600)) { + val view = CodeBlockContainerView(context, config).apply { applyCodeBlockNode(node) } + + fun measure(wrapped: Boolean): Int { + view.wrapped = wrapped + view.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)) + return view.measuredHeight + } + + val unwrapped = measure(false) + assertEquals( + "unwrapped at width $width", + CodeBlockContainerView.measureCodeBlockNodeHeight(node, config, context, width.toFloat(), false), + unwrapped.toFloat(), + 0f, + ) + val wrapped = measure(true) + assertEquals( + "wrapped at width $width", + CodeBlockContainerView.measureCodeBlockNodeHeight(node, config, context, width.toFloat(), true), + wrapped.toFloat(), + 0f, + ) + assertTrue("wrapped $wrapped should be taller than unwrapped $unwrapped at width $width", wrapped > unwrapped) + } + } + + @Test + fun wrapStateSurvivesOnlyInPlaceGrowth() { + val block = "```kotlin\nval a = 1\n```\n" + assertTrue("streaming append", keepsCodeBlockWrapState("Intro\n\n```kotlin\nval a", "Intro\n\n$block")) + assertTrue("identical markdown", keepsCodeBlockWrapState("Intro\n\n$block", "Intro\n\n$block")) + assertFalse("replaced document", keepsCodeBlockWrapState("Intro\n\n$block", "Other\n\n$block")) + assertFalse("block inserted above", keepsCodeBlockWrapState("Intro\n\n$block", "Intro\n\n```\nnew\n```\n\n$block")) + } + + private fun styleConfig(): StyleConfig { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(context) + val codeBlock = + JavaOnlyMap.of( + "fontSize", + 14.0, + "fontFamily", + "monospace", + "fontWeight", + "", + "color", + Color.WHITE, + "lineHeight", + 22.0, + "marginTop", + 0.0, + "marginBottom", + 16.0, + "backgroundColor", + Color.DKGRAY, + "borderColor", + Color.GRAY, + "borderRadius", + 8.0, + "borderWidth", + 1.0, + "padding", + 16.0, + ) + return StyleConfig(JavaOnlyMap.of("codeBlock", codeBlock), context, false, 0f) + } +} From 52251d0ee0c1a0858f88c2645d427a0dfa8edb0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 16:33:16 -0700 Subject: [PATCH 8/8] fix(react-native): keep code block wrap state across streamed tail rewrites and task toggles Apps that preprocess streaming markdown rewrite its unfinished last line (an HTML tag closing, a link relabeled), which is not a pure append. Only a change before the previous document's last line now resets wrap state. Android task toggles also kept resetting it while iOS applies them in place; they now keep it. Co-Authored-By: Claude Opus 5.5 --- .../swmansion/enriched/markdown/EnrichedMarkdown.kt | 12 ++++++++---- .../enriched/markdown/EnrichedMarkdownManager.kt | 3 ++- .../markdown/segments/CodeBlockContainerViewTest.kt | 1 + .../ios/EnrichedMarkdown.mm | 10 +++++++--- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index 31ed95f59..987f30058 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -54,12 +54,13 @@ import java.util.concurrent.ExecutorService import java.util.concurrent.Executors // Code block wrap state is keyed by block position, so it only stays meaningful -// while the document grows in place (streaming appends). Anything else - a +// while the document grows in place. Streaming may rewrite its unfinished last +// line (an HTML tag closing, a link relabeled); anything earlier changing - a // recycled cell, an edited message, a block inserted above - starts unwrapped. internal fun keepsCodeBlockWrapState( previous: String, next: String, -): Boolean = next.startsWith(previous) +): Boolean = next.startsWith(previous.substringBeforeLast('\n', "")) class EnrichedMarkdown( context: Context, @@ -295,9 +296,12 @@ class EnrichedMarkdown( segmentViewFactory = RootFactory() } - fun setMarkdownContent(markdown: String) { + fun setMarkdownContent( + markdown: String, + keepWrapState: Boolean = false, + ) { if (currentMarkdown == markdown) return - if (wrappedCodeBlocks.isNotEmpty() && !keepsCodeBlockWrapState(currentMarkdown, markdown)) { + if (!keepWrapState && wrappedCodeBlocks.isNotEmpty() && !keepsCodeBlockWrapState(currentMarkdown, markdown)) { setWrappedCodeBlocks(emptySet()) dirtyFlags += DirtyFlag.FORCE_HEIGHT } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index bec4d4929..41f308f6d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -63,7 +63,8 @@ class EnrichedMarkdownManager : view.setOnTaskListItemPressCallback { taskIndex, checked, itemText -> val newChecked = !checked val updatedMarkdown = TaskListToggleUtils.toggleAtIndex(view.currentMarkdown, taskIndex, newChecked) - view.setMarkdownContent(updatedMarkdown) + // A checkbox flip keeps every code block where it was, so keep their wrap state too. + view.setMarkdownContent(updatedMarkdown, keepWrapState = true) view.commitProps() emitTaskListItemPress(view, taskIndex, newChecked, itemText) } diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt index 3e60a2bae..2860211f5 100644 --- a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt @@ -67,6 +67,7 @@ class CodeBlockContainerViewTest { val block = "```kotlin\nval a = 1\n```\n" assertTrue("streaming append", keepsCodeBlockWrapState("Intro\n\n```kotlin\nval a", "Intro\n\n$block")) assertTrue("identical markdown", keepsCodeBlockWrapState("Intro\n\n$block", "Intro\n\n$block")) + assertTrue("unfinished last line rewritten", keepsCodeBlockWrapState("$block\nSee 0 && newViewProps.markdown.rfind(oldViewProps.markdown, 0) != 0) { + // the document grows in place. Streaming may rewrite its unfinished last line + // (an HTML tag closing, a link relabeled); anything earlier changing - a + // recycled cell, an edited message, a block inserted above - starts unwrapped. + const auto &previous = oldViewProps.markdown; + const auto lastNewline = previous.rfind('\n'); + const auto stablePrefix = lastNewline == std::string::npos ? std::string() : previous.substr(0, lastNewline); + if (self.wrappedCodeBlockIndices.count > 0 && newViewProps.markdown.rfind(stablePrefix, 0) != 0) { self.wrappedCodeBlockIndices = [NSSet set]; _dirtyFlags |= ENRMDirtyForceHeight; }