From f8d7df1a9bafdcb3c11756caa21174eed084cb7b Mon Sep 17 00:00:00 2001 From: Abdullah1738 Date: Thu, 15 Oct 2026 17:31:35 -0700 Subject: [PATCH 1/5] feat: add a per-block line wrap toggle to code block headers Adds an opt-in `enableCodeBlockWrapToggle` prop to the React Native package. Each top-level fenced code block gets a wrap button next to copy; tapping it wraps that block's lines at the block width, and tapping again returns to horizontal scrolling. Wrap state is a set of document-order indexes of top-level code blocks, fed to the view-free measure pass (part of the iOS measurement cache key and the Android measure hash). It resets when anything before the previous document's last line changes, so streaming and task toggles keep it. Labels come from `accessibilityLabels.codeBlock.wrap` / `.unwrap`. Closes #1009 Co-authored-by: Julius Marminge Co-Authored-By: Claude Opus 5.5 --- docs-md/ACCESSIBILITY.md | 2 + docs-md/API_REFERENCE.md | 12 + .../api-reference/enriched-markdown-text.md | 11 + docs/docs/user-experience/accessibility.md | 4 +- .../enriched/markdown/EnrichedMarkdown.kt | 61 ++++- .../markdown/EnrichedMarkdownManager.kt | 11 +- .../enriched/markdown/MeasurementStore.kt | 28 ++- .../accessibility/AccessibilityLabels.kt | 2 + .../segments/CodeBlockContainerView.kt | 167 +++++++++++-- .../markdown/segments/SegmentViewCreators.kt | 1 + .../utils/common/MarkdownViewManagerUtils.kt | 3 + .../EnrichedMarkdownTextSpec/conversions.h | 2 + .../segments/CodeBlockContainerViewTest.kt | 106 +++++++++ .../ios/EnrichedMarkdown.h | 5 + .../ios/EnrichedMarkdown.mm | 78 +++++++ .../ios/EnrichedMarkdownText.mm | 4 + .../ios/internals/ENRMViewFreeMeasurement.h | 16 +- .../internals/EnrichedMarkdownShadowNode.mm | 28 ++- .../ios/internals/MeasurementCache.h | 17 +- .../ios/internals/ShadowMeasurementUtils.h | 11 +- .../ios/segments/ENRMCodeBlockContainerView.h | 23 ++ .../ios/segments/ENRMCodeBlockContainerView.m | 221 ++++++++++++++++-- .../ios/utils/ENRMAccessibilityLabels.h | 4 + .../src/EnrichedMarkdownNativeComponent.ts | 11 + .../EnrichedMarkdownTextNativeComponent.ts | 5 + .../src/accessibilityLabelDefaults.ts | 11 + .../src/native/EnrichedMarkdownText.tsx | 8 +- .../src/types/AccessibilityLabels.ts | 21 ++ .../src/types/MarkdownTextProps.ts | 13 ++ .../src/web/nativeProps.ts | 1 + 30 files changed, 827 insertions(+), 60 deletions(-) create mode 100644 packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt 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/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md index e58e4c40b..740b1bf58 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -237,6 +237,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. 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 | +| --------- | ------------- | ------------ | +| `boolean` | `false` | iOS, Android | + ### `enableBlockContextMenu` Controls the long-press copy popup on code blocks, tables, block math, and blockquotes/admonitions. @@ -737,6 +745,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/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 c12937c50..751d5c45f 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 @@ -43,6 +43,15 @@ 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 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.substringBeforeLast('\n', "")) + class EnrichedMarkdown( context: Context, ) : ContainerNodeView(context), @@ -141,12 +150,58 @@ 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() } - fun setMarkdownContent(markdown: String) { + fun setMarkdownContent( + markdown: String, + keepWrapState: Boolean = false, + ) { if (currentMarkdown == markdown) return + if (!keepWrapState && wrappedCodeBlocks.isNotEmpty() && !keepsCodeBlockWrapState(currentMarkdown, markdown)) { + setWrappedCodeBlocks(emptySet()) + dirtyFlags += DirtyFlag.FORCE_HEIGHT + } currentMarkdown = markdown renderPending = true } @@ -360,6 +415,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 { @@ -469,6 +527,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 5dd58285c..70f91190b 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 @@ -61,7 +61,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) } @@ -244,6 +245,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 f4840e420..a6a36a9c1 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 @@ -89,6 +89,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?, @@ -141,6 +144,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) { @@ -477,7 +489,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 @@ -554,6 +575,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 @@ -594,7 +616,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/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 cba082576..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 @@ -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,8 @@ 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.accessibility.AccessibilityLabels import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.LineHeightSpan import com.swmansion.enriched.markdown.styles.CodeBlockStyle @@ -47,8 +50,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, @@ -69,9 +73,18 @@ 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 = "`" + 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 +98,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 +179,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 +203,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 +225,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) accessibilityLabels.codeBlockUnwrap else accessibilityLabels.codeBlockWrap + } + + private fun toggleWrap() { + wrapped = !wrapped + onWrapToggle?.invoke(this) } fun applyCodeBlockNode(node: MarkdownASTNode) { @@ -206,15 +267,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 +318,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 +361,16 @@ class CodeBlockContainerView( return super.dispatchTouchEvent(ev) } - private fun isPointInsideCopyButton( + private fun isPointInsideHeaderButton( + x: Float, + y: Float, + ): Boolean = isPointInside(copyButton, x, y) || (wrapButton.visibility == View.VISIBLE && isPointInside(wrapButton, x, y)) + + private fun isPointInside( + view: View, x: Float, y: Float, - ): Boolean = x >= copyButton.left && x < copyButton.right && y >= copyButton.top && y < copyButton.bottom + ): Boolean = x >= view.left && x < view.right && y >= view.top && y < view.bottom private fun copyFencedMarkdown() { if (code.isEmpty()) return @@ -340,7 +417,54 @@ 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 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 +513,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 +530,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/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 c9644d15d..a0c4c512e 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 @@ -145,6 +145,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/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 d4961d13c..8e613e58f 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; folly::dynamic imageRequestHeaders = folly::dynamic::array(); for (const auto &header : props.imageRequestHeaders) { 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..2860211f5 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt @@ -0,0 +1,106 @@ +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")) + assertTrue("unfinished last line rewritten", keepsCodeBlockWrapState("$block\nSee *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 5a0b7e3e6..34be965f6 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -93,6 +93,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 @@ -133,6 +134,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; @@ -217,6 +219,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; @@ -691,6 +694,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]]) { @@ -905,6 +910,7 @@ - (void)applyRenderedSegments:(NSArray *)renderedSegments self->_pendingCodeBlockSegment != nil && i == self->_segmentViews.count - 1; } }]; + [self syncCodeBlockWrapState]; CGRect contentBounds = [self contentBounds]; if (contentBounds.size.width > 0) { @@ -1040,6 +1046,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) { @@ -1052,12 +1059,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) @@ -1110,6 +1158,17 @@ - (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 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; + } } if (_config == nil) { @@ -1211,6 +1270,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; @@ -1278,6 +1350,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 = @@ -1384,6 +1460,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/EnrichedMarkdownText.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm index b7b588b37..b09461dc5 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm @@ -760,6 +760,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/internals/ENRMViewFreeMeasurement.h b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h index 885353f4e..16417ffc5 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -231,11 +231,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])); @@ -294,6 +298,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]; @@ -320,8 +325,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 056225f54..6b3acb078 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm @@ -45,6 +45,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 { @@ -57,13 +68,20 @@ ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight; + // 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; + 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); + }, + 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 5f67471a7..0342e5cc5 100644 --- a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h +++ b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h @@ -45,6 +45,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; @@ -59,15 +61,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); } }; @@ -90,6 +93,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); @@ -194,7 +198,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, @@ -212,6 +216,7 @@ inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFl .md4cFlagsAdmonitions = props.md4cFlags.admonitions, .styleFingerprint = computeStyleFingerprint(props.markdownStyle), .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 9904ae55f..6a30af0ee 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h +++ b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h @@ -85,14 +85,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. `wrappedCodeBlocksFingerprint` folds view + * state outside the props that changes the size (code blocks the user wrapped) + * into the cache key. */ 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), + size_t wrappedCodeBlocksFingerprint = 0) { CGFloat maxWidth = layoutConstraints.maximumSize.width; @@ -117,7 +120,7 @@ ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr 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); @@ -127,7 +130,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}); } diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h index e17d3dfb9..e84d65c52 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h @@ -4,11 +4,14 @@ #import "StyleConfig.h" @class MarkdownASTNode; +@class ENRMAccessibilityLabels; +@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 +25,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; @@ -33,6 +52,10 @@ typedef void (^ENRMCodeBlockPressBlock)(NSString *code, NSString *language); // 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 53029a745..69cd0af86 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" @@ -18,8 +19,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 @@ -42,6 +44,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 +104,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,10 +171,12 @@ 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; @@ -174,7 +215,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 +223,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) { @@ -221,9 +284,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 +327,7 @@ - (instancetype)initWithConfig:(StyleConfig *)config _config = config; _cachedCode = @""; _fenceChar = @"`"; + _wrappedHeightWidth = -1; _dynamicProps = [[ENRMDynamicBlockProps alloc] init]; _headerFont = ENRMCodeBlockHeaderFont(config); _headerLabelLineHeight = ENRMCodeBlockHeaderLabelLineHeight(_headerFont); @@ -274,9 +342,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 +406,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 +443,85 @@ - (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 ? _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 +{ + 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 +564,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 +630,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 +650,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 +747,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 +814,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 && [self wrapActionLabel].length > 0) { + [actions addObject:[[UIAccessibilityCustomAction alloc] initWithName:[self wrapActionLabel] + target:self + selector:@selector(performWrapAccessibilityAction:)]]; + } + return actions; } - (BOOL)performCopyAccessibilityAction:(UIAccessibilityCustomAction *)action @@ -662,6 +837,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/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 32ff76827..cd05db07a 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; } @@ -522,6 +527,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/EnrichedMarkdownTextNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts index 6f31ccc1c..a557505da 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/native/EnrichedMarkdownText.tsx b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx index ca7157e39..73e460b9e 100644 --- a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx @@ -145,6 +145,7 @@ export const EnrichedMarkdownText = ({ onCopyPress, onLatexError, onCodeBlockPress, + enableCodeBlockWrapToggle = false, enableBlockContextMenu = true, enableLinkPreview, selectable = true, @@ -455,7 +456,12 @@ export const EnrichedMarkdownText = ({ }; if (flavor === 'github') { - return ; + return ( + + ); } return ; 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; diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index acf3cfccf..d6aeac827 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -263,6 +263,19 @@ 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 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 + * @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 2f8f98f1a..58db5dd6d 100644 --- a/packages/react-native-enriched-markdown/src/web/nativeProps.ts +++ b/packages/react-native-enriched-markdown/src/web/nativeProps.ts @@ -33,6 +33,7 @@ export const NATIVE_ONLY_PROP_NAMES: Record = { onCopyPress: true, onLatexError: true, enableBlockContextMenu: true, + enableCodeBlockWrapToggle: true, enableLinkPreview: true, selectionHandleColor: true, allowFontScaling: true, From d25182f0c1bff1069594abdf4dd30648a3fd68da Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 16 Oct 2026 19:33:19 +0200 Subject: [PATCH 2/5] fix(ios): write the checked state a task toggle produces into the markdown toggleTaskListItemAtIndex wrote `[ ]` for checked and `[x]` for unchecked, the inverse of Android, since #92. The checkbox looked right because the attributed string is patched separately, but the cached markdown recorded the opposite state, so anything reading it after a tap saw a stale checkbox and a prop echo of the toggled document never matched the local render. Co-Authored-By: Claude Fable 5.1 --- .../segments/CodeBlockContainerViewTest.kt | 106 ------------------ .../ios/utils/TaskListTapUtils.m | 2 +- 2 files changed, 1 insertion(+), 107 deletions(-) delete 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 deleted file mode 100644 index 2860211f5..000000000 --- a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerViewTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -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")) - assertTrue("unfinished last line rewritten", keepsCodeBlockWrapState("$block\nSee Date: Fri, 16 Oct 2026 19:33:25 +0200 Subject: [PATCH 3/5] fix: keep wrap state across task toggles and stroke the Android wrap icon iOS reset the wrapped set when the markdown prop changed before its last line, which a task-toggle echo always does. The reset now skips a prop that equals the markdown already rendered locally, so the reader's choice stands. Android drew the wrap glyph through androidx.core.graphics.PathParser, a RestrictTo API, as a filled shape. WrapGlyph strokes the same Material outline with the copy glyph's paint, so the two header buttons match. Docs note that macOS accepts the prop without a toggle. Comments restating adjacent code are trimmed. CodeBlockContainerViewTest moves to a follow-up PR that adds native coverage on both platforms. Co-Authored-By: Claude Fable 5.1 --- docs-md/API_REFERENCE.md | 2 +- .../api-reference/enriched-markdown-text.md | 2 +- .../enriched/markdown/EnrichedMarkdown.kt | 5 +-- .../segments/CodeBlockContainerView.kt | 25 +++++------- .../enriched/markdown/views/WrapGlyph.kt | 40 +++++++++++++++++++ .../ios/EnrichedMarkdown.mm | 11 +++-- .../internals/EnrichedMarkdownShadowNode.mm | 4 +- .../ios/segments/ENRMCodeBlockContainerView.h | 4 +- .../ios/segments/ENRMCodeBlockContainerView.m | 5 +-- 9 files changed, 66 insertions(+), 32 deletions(-) create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/WrapGlyph.kt diff --git a/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md index 740b1bf58..54d98e8e6 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -239,7 +239,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. 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). +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). macOS accepts the prop but shows no toggle. | Type | Default Value | Platform | | --------- | ------------- | ------------ | 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 767402bca..123abe30c 100644 --- a/docs/docs/react-native/api-reference/enriched-markdown-text.md +++ b/docs/docs/react-native/api-reference/enriched-markdown-text.md @@ -219,7 +219,7 @@ Controls the long-press context menu on block views - fenced code blocks, tables ### `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. +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; macOS accepts the prop but shows no toggle. 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 751d5c45f..b98dc23b2 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 @@ -150,9 +150,8 @@ 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. + // Document-order indexes of the wrapped top-level code blocks, kept across + // renders and mirrored into MeasurementStore for the Yoga measure pass. private var wrappedCodeBlocks: Set = emptySet() var enableCodeBlockWrapToggle: Boolean = false 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 932a44a6e..b075701ed 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,7 +6,6 @@ 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 @@ -26,7 +25,6 @@ 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.accessibility.AccessibilityLabels import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.LineHeightSpan @@ -38,6 +36,7 @@ import com.swmansion.enriched.markdown.utils.text.extensions.applyCodeBlockTextS import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE import com.swmansion.enriched.markdown.views.ContextMenuPopup import com.swmansion.enriched.markdown.views.CopyGlyph +import com.swmansion.enriched.markdown.views.WrapGlyph import kotlin.math.ceil import kotlin.math.roundToInt @@ -98,8 +97,7 @@ class CodeBlockContainerView( rebuildCodeText() } - // Shows the header wrap toggle next to the copy button. Set by the root host - // from the enableCodeBlockWrapToggle prop. + // Header wrap toggle next to copy; the root host sets it from enableCodeBlockWrapToggle. var showsWrapButton: Boolean = false set(value) { if (field == value) return @@ -318,7 +316,6 @@ 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) @@ -417,16 +414,20 @@ class CodeBlockContainerView( override fun getOpacity() = PixelFormat.TRANSLUCENT } - // Material "wrap_text" glyph, drawn from its 24-unit path at the copy glyph's size. + // Material "wrap_text" glyph, stroked like the copy glyph at the same size. private class WrapIconDrawable( color: Int, private val size: Int, ) : Drawable() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL + style = Paint.Style.STROKE + strokeWidth = size / 20f + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND this.color = color } + private val glyph = WrapGlyph() fun setColor(color: Int) { paint.color = color @@ -434,11 +435,9 @@ class CodeBlockContainerView( } 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) + glyph.draw(canvas, bounds.width().toFloat(), paint) canvas.restoreToCount(saved) } @@ -459,12 +458,6 @@ class CodeBlockContainerView( } companion object { - 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. diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/WrapGlyph.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/WrapGlyph.kt new file mode 100644 index 000000000..0318a68ef --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/WrapGlyph.kt @@ -0,0 +1,40 @@ +package com.swmansion.enriched.markdown.views + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF + +/** + * Material "wrap_text" glyph as strokes on a 24-unit grid, drawn with the same + * paint as [CopyGlyph] so the two header buttons match in weight. + */ +internal class WrapGlyph { + private val lines = Path() + private val arrow = Path() + private val loop = RectF() + + fun draw( + canvas: Canvas, + width: Float, + stroke: Paint, + ) { + val u = width / 24f + lines.rewind() + lines.moveTo(4 * u, 6 * u) + lines.lineTo(20 * u, 6 * u) + lines.moveTo(4 * u, 18 * u) + lines.lineTo(10 * u, 18 * u) + lines.moveTo(4 * u, 12 * u) + lines.lineTo(17 * u, 12 * u) + loop.set(15 * u, 12 * u, 19 * u, 16 * u) + lines.arcTo(loop, -90f, 180f) + lines.lineTo(13 * u, 16 * u) + arrow.rewind() + arrow.moveTo(15 * u, 14 * u) + arrow.lineTo(13 * u, 16 * u) + arrow.lineTo(15 * u, 18 * u) + canvas.drawPath(lines, stroke) + canvas.drawPath(arrow, stroke) + } +} diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index 34be965f6..0448655c2 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -1066,9 +1066,7 @@ - (ENRMCodeBlockContainerView *)createCodeBlockViewForSegment:(ENRMCodeBlockSegm 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). +// Pushes wrappedCodeBlockIndices onto the current code block views by position. - (void)syncCodeBlockWrapState { NSSet *wrapped = self.wrappedCodeBlockIndices; @@ -1162,10 +1160,15 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & // 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. + // A task toggle renders its result locally before JS echoes it back as a + // prop; that echo is the document already on screen, so the choice stands. + const bool echoesRendered = + _cachedMarkdown != nil && newViewProps.markdown == std::string(_cachedMarkdown.UTF8String); 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) { + if (self.wrappedCodeBlockIndices.count > 0 && !echoesRendered && + newViewProps.markdown.rfind(stablePrefix, 0) != 0) { self.wrappedCodeBlockIndices = [NSSet set]; _dirtyFlags |= ENRMDirtyForceHeight; } diff --git a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm index 6b3acb078..701f52f92 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm @@ -45,8 +45,8 @@ } } -// 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). +// Order-independent fingerprint of the wrapped set for the cache key; 0 for none, +// and {0} still hashes apart from {}. static size_t ENRMWrappedCodeBlocksFingerprint(NSSet *wrappedCodeBlocks) { size_t h = 0; diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h index e84d65c52..add04e3ac 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h @@ -32,8 +32,8 @@ typedef void (^ENRMCodeBlockWrapToggleBlock)(ENRMCodeBlockContainerView *view); @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. +// Header wrap toggle next to copy (iOS only); the root host sets it from +// enableCodeBlockWrapToggle. @property (nonatomic, assign) BOOL showsWrapButton; // Wraps long lines at the block width instead of scrolling horizontally. diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m index 69cd0af86..cfbe311e7 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m @@ -444,7 +444,6 @@ - (void)layoutHeaderButton 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 } @@ -501,8 +500,8 @@ - (void)setWrapped:(BOOL)wrapped } _wrapped = wrapped; if (!wrapped) { - // The scroll extent reads the unwrapped width, which a rebuild while wrapped - // measured at the wrap width. + // Restore the unwrapped width for the scroll extent; a rebuild while + // wrapped measured at the wrap width. _codeContentView.wrapWidth = 0; _codeSize.width = _codeContentView.usedWidth; } From dda187bfc7719eeda7acf82341c446a11038b91b Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 16 Oct 2026 22:31:25 +0200 Subject: [PATCH 4/5] fix: update the logic acording to latest review --- .../android/app/src/main/AndroidManifest.xml | 2 +- docs-md/API_REFERENCE.md | 2 +- .../api-reference/enriched-markdown-text.md | 2 +- .../ios/ENRMCodeBlockWrapStateTests.mm | 36 ++++++ .../enriched/markdown/CodeBlockWrapState.kt | 107 +++++++++++++++ .../enriched/markdown/EnrichedMarkdown.kt | 71 +++------- .../markdown/EnrichedMarkdownManager.kt | 2 +- .../enriched/markdown/MeasurementStore.kt | 9 +- .../segments/BlockquoteContainerView.kt | 6 +- .../segments/CodeBlockContainerView.kt | 9 +- .../markdown/segments/ContainerNodeView.kt | 12 ++ .../segments/SegmentHeightMeasurer.kt | 24 +++- .../markdown/CodeBlockWrapStateTest.kt | 31 +++++ .../ios/EnrichedMarkdown.h | 9 +- .../ios/EnrichedMarkdown.mm | 94 ++++---------- .../ios/internals/ENRMViewFreeMeasurement.h | 16 ++- .../segments/ENRMBlockquoteContainerView.h | 9 +- .../segments/ENRMBlockquoteContainerView.mm | 6 +- .../ios/segments/ENRMCodeBlockContainerView.h | 11 +- .../ios/segments/ENRMCodeBlockContainerView.m | 13 +- .../ios/segments/ENRMContainerNodeView.h | 3 + .../ios/segments/ENRMContainerNodeView.mm | 5 + .../ios/segments/ENRMSegmentHeightMeasurer.h | 8 +- .../ios/segments/ENRMSegmentHeightMeasurer.mm | 14 +- .../ios/utils/ENRMCodeBlockWrapState.h | 91 +++++++++++++ .../ios/utils/ENRMCodeBlockWrapState.mm | 122 ++++++++++++++++++ .../src/types/MarkdownTextProps.ts | 4 +- 27 files changed, 555 insertions(+), 163 deletions(-) create mode 100644 packages/react-native-enriched-markdown/__tests__/ios/ENRMCodeBlockWrapStateTests.mm create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt create mode 100644 packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/CodeBlockWrapStateTest.kt create mode 100644 packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h create mode 100644 packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm diff --git a/apps/react-native-example/android/app/src/main/AndroidManifest.xml b/apps/react-native-example/android/app/src/main/AndroidManifest.xml index fb78f3974..be948b0f3 100644 --- a/apps/react-native-example/android/app/src/main/AndroidManifest.xml +++ b/apps/react-native-example/android/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ diff --git a/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md index 54d98e8e6..dc16050eb 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -239,7 +239,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. 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). macOS accepts the prop but shows no toggle. +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. The toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). macOS accepts the prop but shows no toggle. | Type | Default Value | Platform | | --------- | ------------- | ------------ | 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 123abe30c..6a3e983db 100644 --- a/docs/docs/react-native/api-reference/enriched-markdown-text.md +++ b/docs/docs/react-native/api-reference/enriched-markdown-text.md @@ -219,7 +219,7 @@ Controls the long-press context menu on block views - fenced code blocks, tables ### `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; macOS accepts the prop but shows no toggle. +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. Needs [`flavor="github"`](#flavor); the toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). iOS and Android only; macOS accepts the prop but shows no toggle. diff --git a/packages/react-native-enriched-markdown/__tests__/ios/ENRMCodeBlockWrapStateTests.mm b/packages/react-native-enriched-markdown/__tests__/ios/ENRMCodeBlockWrapStateTests.mm new file mode 100644 index 000000000..dab614426 --- /dev/null +++ b/packages/react-native-enriched-markdown/__tests__/ios/ENRMCodeBlockWrapStateTests.mm @@ -0,0 +1,36 @@ +#import "../../ios/utils/ENRMCodeBlockWrapState.h" +#import + +@interface ENRMCodeBlockWrapStateTests : XCTestCase +@end + +@implementation ENRMCodeBlockWrapStateTests + +- (void)testKeepsStateWhileTheDocumentGrows +{ + XCTAssertTrue(ENRMKeepsCodeBlockWrapState("a\nb", "a\nb\nc")); + // The unfinished last line may be rewritten. + XCTAssertTrue(ENRMKeepsCodeBlockWrapState("a\n\nc")); + // A single-line document has no stable prefix yet. + XCTAssertTrue(ENRMKeepsCodeBlockWrapState("abc", "xyz")); +} + +- (void)testResetsWhenAnEarlierLineChanges +{ + XCTAssertFalse(ENRMKeepsCodeBlockWrapState("a\nb\nc", "x\nb\nc")); + XCTAssertFalse(ENRMKeepsCodeBlockWrapState("a\nb\nc", "a\nx\nc")); + // Shorter than the stable prefix. + XCTAssertFalse(ENRMKeepsCodeBlockWrapState("a\nb\nc", "a\n")); +} + +- (void)testCursorCountsInVisitOrder +{ + ENRMCodeBlockWrapCursor *cursor = [[ENRMCodeBlockWrapCursor alloc] initWithWrapped:[NSSet setWithArray:@[ @1, @3 ]]]; + XCTAssertFalse([cursor nextIsWrapped]); + XCTAssertTrue([cursor nextIsWrapped]); + XCTAssertFalse([cursor nextIsWrapped]); + XCTAssertTrue([cursor nextIsWrapped]); + XCTAssertFalse([cursor nextIsWrapped]); +} + +@end diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt new file mode 100644 index 000000000..d1dcb3b89 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt @@ -0,0 +1,107 @@ +package com.swmansion.enriched.markdown + +import com.swmansion.enriched.markdown.segments.CodeBlockContainerView + +// Code block wrap state is keyed by block position, so it only stays meaningful +// 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. +// Compares in place: this runs on every streamed chunk while a block is wrapped. +internal fun keepsCodeBlockWrapState( + previous: String, + next: String, +): Boolean { + val cut = previous.lastIndexOf('\n') + return cut < 0 || next.regionMatches(0, previous, 0, cut) +} + +/** + * Which code blocks of one EnrichedMarkdown the user wrapped with the header + * toggle, and the only writer of that choice. + * + * Blocks are identified by document-order index, counted pre-order across + * nesting (a block inside a blockquote takes the next index, like a top-level + * one); [CodeBlockWrapCursor] counts the same way in the view-free measure + * pass. The set lives here rather than on the views, which are rebuilt on + * style and font-scale changes, and is mirrored into MeasurementStore under the + * view tag for that measure pass. + */ +internal class CodeBlockWrapState( + private val viewTag: () -> Int, + private val codeBlockViews: () -> Sequence, + private val onHeightChanged: () -> Unit, +) { + var enabled: Boolean = false + private set + + var indices: Set = emptySet() + private set + + // The markdown prop as last received, which local task toggles don't touch. + private var lastMarkdownProp: String = "" + + /** Returns whether the wrapped set was cleared, so the height must be recomputed. */ + fun setEnabled(value: Boolean): Boolean { + if (enabled == value) return false + enabled = value + val cleared = !value && indices.isNotEmpty() + if (cleared) write(emptySet()) + sync() + return cleared + } + + /** + * Applies the reset rule to a new markdown prop; [rendered] is the document on + * screen, which includes local task toggles. The prop is compared with the + * previous prop, so an app that ignores a checkbox tap and keeps streaming its + * own copy keeps the wrapped blocks; a prop equal to what's rendered is the app + * echoing a task toggle back and keeps them too. Returns whether the wrapped + * set was cleared. + */ + fun onMarkdownProp( + next: String, + rendered: String, + ): Boolean { + val previous = lastMarkdownProp + lastMarkdownProp = next + if (indices.isEmpty() || next == rendered || keepsCodeBlockWrapState(previous, next)) return false + write(emptySet()) + return true + } + + /** Pushes the wrapped set and the toggle onto the current code block views. */ + fun sync() { + codeBlockViews().forEachIndexed { index, view -> + view.onWrapToggle = ::toggle + view.showsWrapButton = enabled + view.wrapped = enabled && index in indices + } + } + + private fun toggle(view: CodeBlockContainerView) { + if (!enabled) return + val index = codeBlockViews().indexOf(view) + if (index < 0) return + write(if (index in indices) indices - index else indices + index) + sync() + onHeightChanged() + } + + private fun write(value: Set) { + indices = value + MeasurementStore.updateWrappedCodeBlocks(viewTag(), value) + } +} + +/** + * Hands out the wrapped flag of each code block in the order a view-free measure + * pass reaches them: pre-order, descending into blockquotes, the same order + * [CodeBlockWrapState] indexes the views in. + */ +class CodeBlockWrapCursor( + private val wrapped: Set, +) { + private var nextIndex = 0 + + fun nextIsWrapped(): Boolean = nextIndex++ in wrapped +} 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 b98dc23b2..c7d04b9fd 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 @@ -43,15 +43,6 @@ 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 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.substringBeforeLast('\n', "")) - class EnrichedMarkdown( context: Context, ) : ContainerNodeView(context), @@ -59,6 +50,10 @@ class EnrichedMarkdown( private enum class DirtyFlag { RECREATE_SEGMENTS, FORCE_HEIGHT, + + // Yoga already measured with inputs that changed after (the wrapped code + // blocks a prop reset cleared), so it has to be asked to measure again. + REMEASURE, } private val parser = Parser.shared @@ -150,57 +145,31 @@ class EnrichedMarkdown( dynamicProps.enableCodeBlockPress = value } - // Document-order indexes of the wrapped top-level code blocks, kept across - // renders and mirrored into MeasurementStore for the Yoga measure pass. - private var wrappedCodeBlocks: Set = emptySet() + private val codeBlockWrapState = + CodeBlockWrapState(viewTag = { id }, codeBlockViews = ::codeBlockViews, onHeightChanged = ::onImageLayoutChanged) - var enableCodeBlockWrapToggle: Boolean = false + var enableCodeBlockWrapToggle: Boolean + get() = codeBlockWrapState.enabled 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++ - } + if (codeBlockWrapState.setEnabled(value)) onImageLayoutChanged() } - } - - 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() } + // isTaskToggle marks a checkbox flip rendered locally ahead of the prop; it + // moves no code block, so it skips the wrap state's reset rule. fun setMarkdownContent( markdown: String, - keepWrapState: Boolean = false, + isTaskToggle: Boolean = false, ) { - if (currentMarkdown == markdown) return - if (!keepWrapState && wrappedCodeBlocks.isNotEmpty() && !keepsCodeBlockWrapState(currentMarkdown, markdown)) { - setWrappedCodeBlocks(emptySet()) - dirtyFlags += DirtyFlag.FORCE_HEIGHT + // The shadow node measures the new markdown before this setter runs, still + // with the old wrapped set, so a reset needs Yoga to measure again. + if (!isTaskToggle && codeBlockWrapState.onMarkdownProp(markdown, currentMarkdown)) { + dirtyFlags += DirtyFlag.REMEASURE } + if (currentMarkdown == markdown) return currentMarkdown = markdown renderPending = true } @@ -414,7 +383,7 @@ class EnrichedMarkdown( segmentViews.filterIsInstance().forEach { it.accessibilityLabels = labels } - segmentViews.filterIsInstance().forEach { + codeBlockViews().forEach { it.accessibilityLabels = labels } mathContainerClass?.let { mathClass -> @@ -515,6 +484,7 @@ class EnrichedMarkdown( val reset = DirtyFlag.RECREATE_SEGMENTS in dirtyFlags val forceHeight = DirtyFlag.FORCE_HEIGHT in dirtyFlags + val remeasure = DirtyFlag.REMEASURE in dirtyFlags dirtyFlags.clear() val topologyChanged = applySegments(renderedSegments, reset) @@ -526,7 +496,7 @@ class EnrichedMarkdown( view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1 } } - syncCodeBlockWrapState() + codeBlockWrapState.sync() if (width > 0) { val heightBefore = computeSegmentsTotalHeight() @@ -538,6 +508,7 @@ class EnrichedMarkdown( requestLayout() } } + if (remeasure) onImageLayoutChanged() } private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true 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 70f91190b..e8cab5964 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 @@ -62,7 +62,7 @@ class EnrichedMarkdownManager : val newChecked = !checked val updatedMarkdown = TaskListToggleUtils.toggleAtIndex(view.currentMarkdown, taskIndex, newChecked) // A checkbox flip keeps every code block where it was, so keep their wrap state too. - view.setMarkdownContent(updatedMarkdown, keepWrapState = true) + view.setMarkdownContent(updatedMarkdown, isTaskToggle = true) view.commitProps() emitTaskListItemPress(view, taskIndex, newChecked, itemText) } 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 a6a36a9c1..c9a9420d3 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 @@ -89,7 +89,7 @@ object MeasurementStore { private val streamingCodeBlockModes = ConcurrentHashMap() - // Top-level code blocks the user wrapped, by document-order index (see EnrichedMarkdown). + // Code blocks the user wrapped, by document-order index across nesting (see CodeBlockWrapState). private val wrappedCodeBlocks = ConcurrentHashMap>() private fun resolveFontScalingSettings( @@ -575,7 +575,7 @@ object MeasurementStore { val lastIndex = renderedSegments.lastIndex var totalHeightPx = 0f var maxContentWidthPx = 0f - var codeBlockIndex = 0 + val wrapCursor = CodeBlockWrapCursor(wrapped) for ((index, segment) in renderedSegments.withIndex()) { val isLastSegment = index == lastIndex @@ -617,8 +617,7 @@ object MeasurementStore { is RenderedSegment.CodeBlock -> { totalHeightPx += style.codeBlockStyle.marginTop totalHeightPx += - CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width, codeBlockIndex in wrapped) - codeBlockIndex++ + CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width, wrapCursor.nextIsWrapped()) maxContentWidthPx = width if (includeBottomMargin) { totalHeightPx += style.codeBlockStyle.marginBottom @@ -627,7 +626,7 @@ object MeasurementStore { is RenderedSegment.Blockquote -> { totalHeightPx += style.blockquoteStyle.marginTop - totalHeightPx += BlockquoteContainerView.measureBlockquoteNodeHeight(segment.node, style, context, width) + totalHeightPx += BlockquoteContainerView.measureBlockquoteNodeHeight(segment.node, style, context, width, wrapCursor) maxContentWidthPx = width if (includeBottomMargin) { totalHeightPx += style.blockquoteStyle.marginBottom diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt index 8027e6054..b56010ca7 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt @@ -13,6 +13,7 @@ import android.view.View import androidx.core.graphics.withSave import com.facebook.react.common.ReactConstants import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles +import com.swmansion.enriched.markdown.CodeBlockWrapCursor import com.swmansion.enriched.markdown.EnrichedMarkdownInternalText import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.styles.BlockquoteStyle @@ -345,7 +346,8 @@ class BlockquoteContainerView( * The children are summed at the reduced inner width (outer minus horizontal * inset) and the vertical inset is added; nested quotes recurse through this * same function via SegmentHeightMeasurer. Math child heights use the shared - * estimate to avoid a main-thread measure from the layout pass. + * estimate to avoid a main-thread measure from the layout pass. Wrapped code + * blocks (wrapCursor) lay out at the inner width, as the views do. * * Potential win: this runs splitASTIntoSegments + render for measurement, and * applyBlockquoteNode runs them again for display, so each nesting level renders @@ -359,6 +361,7 @@ class BlockquoteContainerView( config: StyleConfig, context: Context, width: Float, + wrapCursor: CodeBlockWrapCursor? = null, ): Float { val style = config.blockquoteStyle val leftInset = ceil(style.borderWidth + style.gapWidth + style.padding).toInt() @@ -388,6 +391,7 @@ class BlockquoteContainerView( breakStrategy = breakStrategy, allowTrailingMargin = false, mathHeightForIndex = { estimateMathHeight(config) }, + wrapCursor = wrapCursor, ) return childrenHeight + verticalInset * 2f + headerReserved 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 b075701ed..604eac25c 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 @@ -97,7 +97,7 @@ class CodeBlockContainerView( rebuildCodeText() } - // Header wrap toggle next to copy; the root host sets it from enableCodeBlockWrapToggle. + // Header wrap toggle next to copy; CodeBlockWrapState sets it from enableCodeBlockWrapToggle. var showsWrapButton: Boolean = false set(value) { if (field == value) return @@ -106,7 +106,8 @@ class CodeBlockContainerView( requestLayout() } - // Wraps long lines at the block width instead of scrolling horizontally. + // Wraps long lines at the block width instead of scrolling horizontally. Only + // CodeBlockWrapState writes it; the header toggle just asks through onWrapToggle. var wrapped: Boolean = false set(value) { if (field == value) return @@ -116,8 +117,7 @@ class CodeBlockContainerView( requestLayout() } - // Fired after the header toggle flips [wrapped], so the host can remember the - // choice and re-measure. + // Fired when the header toggle is pressed; the host flips [wrapped] and re-measures. var onWrapToggle: ((CodeBlockContainerView) -> Unit)? = null private val textView = @@ -234,7 +234,6 @@ class CodeBlockContainerView( } private fun toggleWrap() { - wrapped = !wrapped onWrapToggle?.invoke(this) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt index 016d11dd3..61be99d9d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt @@ -34,6 +34,18 @@ open class ContainerNodeView( protected var trailingMarginEnabled: Boolean = false + // Code block views in document order, descending into nested blockquotes: the + // order CodeBlockWrapState indexes them in. + internal fun codeBlockViews(): Sequence = + sequence { + segmentViews.forEach { view -> + when (view) { + is CodeBlockContainerView -> yield(view) + is ContainerNodeView -> yieldAll(view.codeBlockViews()) + } + } + } + /** * Reconciles the current child views against renderedSegments, attaching and * removing views as needed and updating segmentViews/segmentSignatures. diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentHeightMeasurer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentHeightMeasurer.kt index 162ebfbff..a67c70708 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentHeightMeasurer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentHeightMeasurer.kt @@ -6,6 +6,7 @@ import android.os.Build import android.text.Layout import android.text.StaticLayout import android.text.TextPaint +import com.swmansion.enriched.markdown.CodeBlockWrapCursor import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.text.extensions.replaceMathSpansWithPlaceholders import com.swmansion.enriched.markdown.utils.text.span.prepareWidthAwareSpans @@ -20,7 +21,9 @@ import kotlin.math.ceil * Math heights must be measured up front on the main thread by the caller and * supplied through mathHeightForIndex, because the summation runs off the main * thread. Blockquote children recurse through - * BlockquoteContainerView.measureBlockquoteNodeHeight. + * BlockquoteContainerView.measureBlockquoteNodeHeight. wrapCursor, when given, + * says which code blocks the user wrapped; it is shared down the recursion so + * nested blocks keep counting in document order. */ object SegmentHeightMeasurer { fun measureSegmentsHeight( @@ -32,6 +35,7 @@ object SegmentHeightMeasurer { breakStrategy: Int, allowTrailingMargin: Boolean, mathHeightForIndex: (Int) -> Float, + wrapCursor: CodeBlockWrapCursor? = null, ): Float { val widthPx = ceil(contentWidthPx).toInt().coerceAtLeast(1) val lastIndex = segments.lastIndex @@ -69,7 +73,14 @@ object SegmentHeightMeasurer { is RenderedSegment.CodeBlock -> { totalHeightPx += style.codeBlockStyle.marginTop - totalHeightPx += CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, contentWidthPx) + totalHeightPx += + CodeBlockContainerView.measureCodeBlockNodeHeight( + segment.node, + style, + context, + contentWidthPx, + wrapCursor?.nextIsWrapped() ?: false, + ) if (includeBottomMargin) { totalHeightPx += style.codeBlockStyle.marginBottom } @@ -79,7 +90,14 @@ object SegmentHeightMeasurer { // A quote encountered here is always nested inside another quote, so it // carries no vertical margin (only the outermost quote does, added by // MeasurementStore); this mirrors BlockquoteContainerView.nested. - totalHeightPx += BlockquoteContainerView.measureBlockquoteNodeHeight(segment.node, style, context, contentWidthPx) + totalHeightPx += + BlockquoteContainerView.measureBlockquoteNodeHeight( + segment.node, + style, + context, + contentWidthPx, + wrapCursor, + ) } is RenderedSegment.Video -> { diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/CodeBlockWrapStateTest.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/CodeBlockWrapStateTest.kt new file mode 100644 index 000000000..00a04edb4 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/CodeBlockWrapStateTest.kt @@ -0,0 +1,31 @@ +package com.swmansion.enriched.markdown + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CodeBlockWrapStateTest { + @Test + fun keepsStateWhileTheDocumentGrows() { + assertTrue(keepsCodeBlockWrapState("a\nb", "a\nb\nc")) + // The unfinished last line may be rewritten. + assertTrue(keepsCodeBlockWrapState("a\n\nc")) + // A single-line document has no stable prefix yet. + assertTrue(keepsCodeBlockWrapState("abc", "xyz")) + } + + @Test + fun resetsWhenAnEarlierLineChanges() { + assertFalse(keepsCodeBlockWrapState("a\nb\nc", "x\nb\nc")) + assertFalse(keepsCodeBlockWrapState("a\nb\nc", "a\nx\nc")) + // Shorter than the stable prefix. + assertFalse(keepsCodeBlockWrapState("a\nb\nc", "a\n")) + } + + @Test + fun cursorCountsInVisitOrder() { + val cursor = CodeBlockWrapCursor(setOf(1, 3)) + assertEquals(listOf(false, true, false, true, false), List(5) { cursor.nextIsWrapped() }) + } +} diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h index f13434d8a..1d4df073d 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h @@ -21,10 +21,11 @@ NS_ASSUME_NONNULL_BEGIN /// 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; +/// Document-order indexes, counted across nesting, of the code blocks the user +/// wrapped with the header toggle (see ENRMCodeBlockWrapState). Written on main, +/// read by the shadow-node measure pass on any thread (atomic copy), so the +/// measured height follows the wrapped blocks. +@property (nonatomic, 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 0448655c2..3fa8b849e 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -28,6 +28,7 @@ #endif #import "ENRMBlockquoteContainerView.h" #import "ENRMCodeBlockContainerView.h" +#import "ENRMCodeBlockWrapState.h" #import "ENRMDynamicBlockProps.h" #import "ENRMSpoilerCapable.h" #import "ENRMSpoilerOverlayView.h" @@ -93,7 +94,6 @@ - (void)emitContextMenuItemPress:(NSString *)itemText selectedText:(NSString *)selectedText selectionStart:(NSUInteger)selectionStart selectionEnd:(NSUInteger)selectionEnd; -@property (atomic, copy, readwrite) NSSet *wrappedCodeBlockIndices; @end #if !TARGET_OS_OSX @@ -134,7 +134,7 @@ @implementation EnrichedMarkdown { ENRMCodeBlockStreamingMode _codeBlockStreamingMode; // Trailing code block whose closing fence hasn't streamed in yet, if any. ENRMCodeBlockSegment *_pendingCodeBlockSegment; - BOOL _enableCodeBlockWrapToggle; + ENRMCodeBlockWrapState *_codeBlockWrapState; size_t _renderedStyleFingerprint; size_t _pendingStyleFingerprint; @@ -219,7 +219,21 @@ - (instancetype)initWithFrame:(CGRect)frame _enableLinkPreview = YES; _enableTaskListItemToggle = YES; _enableImagePress = NO; - self.wrappedCodeBlockIndices = [NSSet set]; + __weak EnrichedMarkdown *weakWrapSelf = self; + _codeBlockWrapState = [[ENRMCodeBlockWrapState alloc] + initWithCodeBlockViews:^{ + NSMutableArray *views = [NSMutableArray array]; + EnrichedMarkdown *strongSelf = weakWrapSelf; + if (strongSelf) { + ENRMCollectCodeBlockViews(strongSelf->_segmentViews, views); + } + return views; + } + onHeightChanged:^{ + EnrichedMarkdown *strongSelf = weakWrapSelf; + [strongSelf setNeedsLayout]; + [strongSelf requestHeightUpdate]; + }]; _dynamicBlockProps = [[ENRMDynamicBlockProps alloc] init]; _linkContextMenus = [[ENRMLinkContextMenus alloc] init]; __weak EnrichedMarkdown *weakMenuSelf = self; @@ -910,7 +924,7 @@ - (void)applyRenderedSegments:(NSArray *)renderedSegments self->_pendingCodeBlockSegment != nil && i == self->_segmentViews.count - 1; } }]; - [self syncCodeBlockWrapState]; + [_codeBlockWrapState sync]; CGRect contentBounds = [self contentBounds]; if (contentBounds.size.width > 0) { @@ -1059,49 +1073,15 @@ - (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; } -// Pushes wrappedCodeBlockIndices onto the current code block views by position. -- (void)syncCodeBlockWrapState +- (NSSet *)wrappedCodeBlockIndices { - 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++; - } - } + return _codeBlockWrapState.indices; } - (void)animateBlockViewIfNeeded:(RCTUIView *)view @@ -1156,20 +1136,11 @@ - (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 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. // A task toggle renders its result locally before JS echoes it back as a - // prop; that echo is the document already on screen, so the choice stands. - const bool echoesRendered = - _cachedMarkdown != nil && newViewProps.markdown == std::string(_cachedMarkdown.UTF8String); - 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 && !echoesRendered && - newViewProps.markdown.rfind(stablePrefix, 0) != 0) { - self.wrappedCodeBlockIndices = [NSSet set]; + // prop, so the rendered document is handed over for the echo check. + if ([_codeBlockWrapState markdownPropDidChangeFrom:oldViewProps.markdown + to:newViewProps.markdown + rendered:_cachedMarkdown]) { _dirtyFlags |= ENRMDirtyForceHeight; } } @@ -1273,17 +1244,9 @@ - (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 ([_codeBlockWrapState updateEnabled:newViewProps.enableCodeBlockWrapToggle]) { + [self setNeedsLayout]; + [self requestHeightUpdate]; } if (newViewProps.streamingAnimation != oldViewProps.streamingAnimation) { @@ -1463,8 +1426,7 @@ - (void)prepareForRecycle _tableStreamingMode = ENRMTableStreamingModeProgressive; _codeBlockStreamingMode = ENRMCodeBlockStreamingModeProgressive; _pendingCodeBlockSegment = nil; - _enableCodeBlockWrapToggle = NO; - self.wrappedCodeBlockIndices = [NSSet set]; + [_codeBlockWrapState reset]; _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 16417ffc5..1952d4372 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -2,6 +2,7 @@ #import "ENRMBlockquoteContainerView.h" #import "ENRMCodeBlockContainerView.h" +#import "ENRMCodeBlockWrapState.h" #import "ENRMFeatureFlags.h" #import "ENRMLinkRegexProps.h" #import "ENRMMarkdownParser.h" @@ -231,9 +232,10 @@ 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. + * `wrappedCodeBlocks` holds the document-order indexes, counted across nesting, + * of the code blocks the user wrapped (EnrichedMarkdown.wrappedCodeBlockIndices); + * those measure at the block width (a quote's inner width when nested) instead + * of their unwrapped height. */ template static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedProps, CGFloat maxWidth, CGFloat fontScale, @@ -298,7 +300,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; + ENRMCodeBlockWrapCursor *wrapCursor = [[ENRMCodeBlockWrapCursor alloc] initWithWrapped:wrappedCodeBlocks]; for (NSUInteger i = 0; i < segments.count; i++) { ENRMRenderedSegment *segment = segments[i]; @@ -326,12 +328,11 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro } else if (segment.kind == ENRMSegmentKindCodeBlock && segment.codeBlockSegment) { yOffset += config.codeBlockMarginTop; MarkdownASTNode *codeBlockNode = segment.codeBlockSegment.codeBlockNode; - yOffset += [wrappedCodeBlocks containsObject:@(codeBlockIndex)] + yOffset += [wrapCursor nextIsWrapped] ? [ENRMCodeBlockContainerView measureWrappedHeightForCodeBlockNode:codeBlockNode config:config maxWidth:maxWidth] : [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:codeBlockNode config:config]; - codeBlockIndex++; maxContentWidth = maxWidth; if (shouldAddBottomMargin) { yOffset += config.codeBlockMarginBottom; @@ -343,7 +344,8 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro maxWidth:maxWidth pointScaleFactor:pointScaleFactor allowFontScaling:typedProps.allowFontScaling - lineBreakStrategy:lineBreakStrategy]; + lineBreakStrategy:lineBreakStrategy + wrapCursor:wrapCursor]; maxContentWidth = maxWidth; if (shouldAddBottomMargin) { yOffset += config.blockquoteMarginBottom; diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.h index 1657128ae..3349efc7b 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.h @@ -6,6 +6,7 @@ @class MarkdownASTNode; @class ENRMAccessibilityLabels; +@class ENRMCodeBlockWrapCursor; NS_ASSUME_NONNULL_BEGIN @@ -46,13 +47,17 @@ typedef void (^ENRMBlockquoteLinkBlock)(NSString *url); // sums the child segments at the reduced inner width and adds the vertical // inset, recursing into itself for nested quotes. pointScaleFactor is the layout // context's scale (the visible instance path uses RCTScreenScale), threaded so -// the per-text-segment pixel rounding matches the laid-out height. +// the per-text-segment pixel rounding matches the laid-out height. wrapCursor, +// when given, says which code blocks the user wrapped and is shared down the +// recursion so nested blocks keep counting in document order; wrapped ones lay +// out at the inner width, as the views do. + (CGFloat)measureHeightForBlockquoteNode:(MarkdownASTNode *)node config:(StyleConfig *)config maxWidth:(CGFloat)maxWidth pointScaleFactor:(CGFloat)pointScaleFactor allowFontScaling:(BOOL)allowFontScaling - lineBreakStrategy:(NSLineBreakStrategy)lineBreakStrategy; + lineBreakStrategy:(NSLineBreakStrategy)lineBreakStrategy + wrapCursor:(nullable ENRMCodeBlockWrapCursor *)wrapCursor; // Consumer text props threaded into the quote's inner content so it honors them // the same way the root document path does, instead of assuming defaults. Set by diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm index 79c79cf67..036e74816 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm @@ -175,6 +175,8 @@ - (void)setAccessibilityLabels:(ENRMAccessibilityLabels *)accessibilityLabels ((TableContainerView *)child).accessibilityLabels = accessibilityLabels; } else if ([child isKindOfClass:[ENRMBlockquoteContainerView class]]) { ((ENRMBlockquoteContainerView *)child).accessibilityLabels = accessibilityLabels; + } else if ([child isKindOfClass:[ENRMCodeBlockContainerView class]]) { + ((ENRMCodeBlockContainerView *)child).accessibilityLabels = accessibilityLabels; } #if ENRICHED_MARKDOWN_MATH else if ([child isKindOfClass:[ENRMMathContainerView class]]) { @@ -261,6 +263,7 @@ - (ENRMSegmentViewRegistry *)buildChildRegistry ENRMCodeBlockContainerView *view = [[ENRMCodeBlockContainerView alloc] initWithConfig:config]; ENRMBlockquoteContainerView *strongSelf = weakSelf; view.dynamicProps = strongSelf.dynamicProps; + view.accessibilityLabels = strongSelf.accessibilityLabels; view.onCopyPress = ^(NSString *code, NSString *language) { ENRMBlockquoteContainerView *s = weakSelf; if (s.onCopyPress) @@ -400,6 +403,7 @@ + (CGFloat)measureHeightForBlockquoteNode:(MarkdownASTNode *)node pointScaleFactor:(CGFloat)pointScaleFactor allowFontScaling:(BOOL)allowFontScaling lineBreakStrategy:(NSLineBreakStrategy)lineBreakStrategy + wrapCursor:(ENRMCodeBlockWrapCursor *)wrapCursor { UIEdgeInsets insets = ENRMBlockquoteContentInsetsForNode(config, ENRMAdmonitionTypeForNode(node) != nil); CGFloat innerWidth = MAX(maxWidth - insets.left - insets.right, 1); @@ -408,7 +412,7 @@ + (CGFloat)measureHeightForBlockquoteNode:(MarkdownASTNode *)node ENRMRenderBlockquoteChildren(node, config, allowFontScaling, lineBreakStrategy); CGFloat childrenHeight = ENRMMeasureSegmentsHeightViewFree( rendered, config, innerWidth, /*allowTrailingMargin*/ NO, pointScaleFactor, ENRMWritingDirectionModeFirstStrong, - NSWritingDirectionNatural, allowFontScaling, lineBreakStrategy); + NSWritingDirectionNatural, allowFontScaling, lineBreakStrategy, wrapCursor); return childrenHeight + insets.top + insets.bottom; } diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h index add04e3ac..4afebd0dc 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h @@ -32,15 +32,16 @@ typedef void (^ENRMCodeBlockWrapToggleBlock)(ENRMCodeBlockContainerView *view); @property (nonatomic, strong) StyleConfig *config; -// Header wrap toggle next to copy (iOS only); the root host sets it from -// enableCodeBlockWrapToggle. +// Header wrap toggle next to copy (iOS only); ENRMCodeBlockWrapState sets it +// from enableCodeBlockWrapToggle. @property (nonatomic, assign) BOOL showsWrapButton; -// Wraps long lines at the block width instead of scrolling horizontally. +// Wraps long lines at the block width instead of scrolling horizontally. Only +// ENRMCodeBlockWrapState writes it; the header toggle just asks through onWrapToggle. @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. +// Fired when the header toggle or the VoiceOver action is used; the host flips +// `wrapped` and re-measures. @property (nonatomic, copy, nullable) ENRMCodeBlockWrapToggleBlock onWrapToggle; // True until the closing fence arrives: highlighting is deferred and copying is diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m index cfbe311e7..ad6a4e010 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m @@ -466,6 +466,8 @@ - (void)setShowsWrapButton:(BOOL)showsWrapButton } _wrapButton.hidden = !showsWrapButton; [self setNeedsLayout]; + // The language label's room shrinks by one button. + [self setNeedsDisplay]; } - (void)updateWrapButtonAppearance @@ -517,7 +519,6 @@ - (void)setWrapped:(BOOL)wrapped - (void)toggleWrap { - self.wrapped = !_wrapped; if (self.onWrapToggle) { self.onWrapToggle(self); } @@ -703,7 +704,15 @@ - (void)drawRect:(CGRect)rect } if (_languageLabel.length > 0) { - [_languageLabel drawAtPoint:CGPointMake(inset, [self headerCenterY] - _languageLabelSize.height / 2)]; + // Keep clear of the header buttons, as Android caps its label view; a long + // name is cut short with an ellipsis. + CGFloat headerButtonsWidth = _showsWrapButton ? headerH * 2 : headerH; + CGFloat labelMaxWidth = MAX(self.bounds.size.width - inset * 2 - headerButtonsWidth, 0); + CGRect labelRect = CGRectMake(inset, [self headerCenterY] - _languageLabelSize.height / 2, + MIN(_languageLabelSize.width, labelMaxWidth), _languageLabelSize.height); + [_languageLabel drawWithRect:labelRect + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingTruncatesLastVisibleLine + context:nil]; } } diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.h index 41e522d05..17b9b9d2f 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.h @@ -39,6 +39,9 @@ NS_ASSUME_NONNULL_BEGIN // Whether the last child contributes its trailing bottom margin. @property (nonatomic, assign) BOOL allowTrailingMargin; +// The current child views in document order. +@property (nonatomic, readonly) NSArray *segmentViews; + // Reconciles the current child views against renderedSegments, attaching and // removing subviews as needed. Requests a re-layout. - (void)applySegments:(NSArray *)renderedSegments reset:(BOOL)reset; diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.mm b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.mm index 448840013..989f46c3a 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.mm +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.mm @@ -32,6 +32,11 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +- (NSArray *)segmentViews +{ + return [_segmentViews copy]; +} + #if TARGET_OS_OSX - (BOOL)isFlipped { diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.h b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.h index 0f26dccfc..d7877b31e 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.h +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.h @@ -7,6 +7,8 @@ NS_ASSUME_NONNULL_BEGIN +@class ENRMCodeBlockWrapCursor; + /** * View-free height summation for a vertical stack of already-rendered * segments at a fixed content width. This is the iOS counterpart of Android's @@ -22,12 +24,14 @@ NS_ASSUME_NONNULL_BEGIN * +[ENRMBlockquoteContainerView measureHeightForBlockquoteNode:...] (recursion). * Per-kind margins come from the config exactly as computeSegmentLayout applies * them: marginTop before, marginBottom after unless it is the last segment and - * trailing margins are disabled. + * trailing margins are disabled. Code blocks the user wrapped (wrapCursor, + * shared with nested quotes) measure at contentWidth. */ CGFloat ENRMMeasureSegmentsHeightViewFree(NSArray *segments, StyleConfig *config, CGFloat contentWidth, BOOL allowTrailingMargin, CGFloat pointScaleFactor, ENRMWritingDirectionMode writingDirectionMode, NSWritingDirection resolvedLayoutDirection, BOOL allowFontScaling, - NSLineBreakStrategy lineBreakStrategy); + NSLineBreakStrategy lineBreakStrategy, + ENRMCodeBlockWrapCursor *_Nullable wrapCursor); NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.mm b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.mm index a8328804a..c60364677 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.mm +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.mm @@ -1,6 +1,7 @@ #import "ENRMSegmentHeightMeasurer.h" #import "ENRMBlockquoteContainerView.h" #import "ENRMCodeBlockContainerView.h" +#import "ENRMCodeBlockWrapState.h" #import "ENRMFeatureFlags.h" #import "ENRMTextRenderer.h" #import "ENRMViewFreeMeasurement.h" @@ -16,7 +17,7 @@ CGFloat ENRMMeasureSegmentsHeightViewFree(NSArray *segmen CGFloat contentWidth, BOOL allowTrailingMargin, CGFloat pointScaleFactor, ENRMWritingDirectionMode writingDirectionMode, NSWritingDirection resolvedLayoutDirection, BOOL allowFontScaling, - NSLineBreakStrategy lineBreakStrategy) + NSLineBreakStrategy lineBreakStrategy, ENRMCodeBlockWrapCursor *wrapCursor) { if (segments.count == 0) { return 0; @@ -49,8 +50,12 @@ CGFloat ENRMMeasureSegmentsHeightViewFree(NSArray *segmen } } else if (segment.kind == ENRMSegmentKindCodeBlock && segment.codeBlockSegment) { totalHeight += config.codeBlockMarginTop; - totalHeight += [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:segment.codeBlockSegment.codeBlockNode - config:config]; + MarkdownASTNode *codeBlockNode = segment.codeBlockSegment.codeBlockNode; + totalHeight += [wrapCursor nextIsWrapped] + ? [ENRMCodeBlockContainerView measureWrappedHeightForCodeBlockNode:codeBlockNode + config:config + maxWidth:contentWidth] + : [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:codeBlockNode config:config]; if (shouldAddBottomMargin) { totalHeight += config.codeBlockMarginBottom; } @@ -64,7 +69,8 @@ CGFloat ENRMMeasureSegmentsHeightViewFree(NSArray *segmen maxWidth:contentWidth pointScaleFactor:pointScaleFactor allowFontScaling:allowFontScaling - lineBreakStrategy:lineBreakStrategy]; + lineBreakStrategy:lineBreakStrategy + wrapCursor:wrapCursor]; } #if ENRICHED_MARKDOWN_MATH else if (segment.kind == ENRMSegmentKindMath && segment.mathSegment) { diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h new file mode 100644 index 000000000..6ad5ca1c7 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h @@ -0,0 +1,91 @@ +#pragma once + +#import "ENRMCodeBlockContainerView.h" +#import "ENRMUIKit.h" + +#ifdef __cplusplus +#include +#endif + +NS_ASSUME_NONNULL_BEGIN + +#ifdef __cplusplus +// Code block wrap state is keyed by block position, so it only stays meaningful +// 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. +// Compares in place: this runs on every streamed chunk while a block is wrapped. +inline bool ENRMKeepsCodeBlockWrapState(const std::string &previous, const std::string &next) +{ + const auto cut = previous.rfind('\n'); + return cut == std::string::npos || next.compare(0, cut, previous, 0, cut) == 0; +} +#endif + +// Appends the code block views among `segmentViews` in document order, +// descending into nested blockquotes: the order ENRMCodeBlockWrapState indexes +// them in. +void ENRMCollectCodeBlockViews(NSArray *segmentViews, + NSMutableArray *codeBlockViews); + +/** + * Hands out the wrapped flag of each code block in the order a view-free + * measure pass reaches them: pre-order, descending into blockquotes, the same + * order ENRMCodeBlockWrapState indexes the views in. + */ +@interface ENRMCodeBlockWrapCursor : NSObject + +- (instancetype)initWithWrapped:(nullable NSSet *)wrapped; + +- (BOOL)nextIsWrapped; + +@end + +/** + * Which code blocks of one EnrichedMarkdown the user wrapped with the header + * toggle, and the only writer of that choice; mirrors Android's + * CodeBlockWrapState. + * + * Blocks are identified by document-order index, counted pre-order across + * nesting (a block inside a blockquote takes the next index, like a top-level + * one). The set lives here rather than on the views, which are rebuilt on style, + * font-scale and pill-content changes, and the shadow node's view-free measure + * pass reads it through EnrichedMarkdown.wrappedCodeBlockIndices. + */ +@interface ENRMCodeBlockWrapState : NSObject + +// `codeBlockViews` returns the host's current code block views (see +// ENRMCollectCodeBlockViews); `onHeightChanged` re-lays out and re-measures it +// after a toggle. +- (instancetype)initWithCodeBlockViews:(NSArray * (^)(void))codeBlockViews + onHeightChanged:(void (^)(void))onHeightChanged; + +@property (nonatomic, readonly) BOOL enabled; + +// Written on main, read by the shadow-node measure pass on any thread. +@property (atomic, copy, readonly) NSSet *indices; + +// Returns whether the wrapped set was cleared, so the height must be recomputed. +- (BOOL)updateEnabled:(BOOL)enabled; + +#ifdef __cplusplus +// Applies the reset rule to a new markdown prop; `rendered` is the document on +// screen, which includes local task toggles. The prop is compared with the +// previous prop, so an app that ignores a checkbox tap and keeps streaming its +// own copy keeps the wrapped blocks; a prop equal to what's rendered is the app +// echoing a task toggle back and keeps them too. Returns whether the wrapped set +// was cleared. +- (BOOL)markdownPropDidChangeFrom:(const std::string &)previous + to:(const std::string &)next + rendered:(nullable NSString *)rendered; +#endif + +// Pushes the wrapped set and the toggle onto the current code block views. +- (void)sync; + +// Back to the initial state (toggle off, nothing wrapped) for a recycled view. +- (void)reset; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm new file mode 100644 index 000000000..c65aee3a4 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm @@ -0,0 +1,122 @@ +#import "ENRMCodeBlockWrapState.h" +#import "ENRMContainerNodeView.h" + +void ENRMCollectCodeBlockViews(NSArray *segmentViews, + NSMutableArray *codeBlockViews) +{ + for (RCTUIView *view in segmentViews) { + if ([view isKindOfClass:[ENRMCodeBlockContainerView class]]) { + [codeBlockViews addObject:(ENRMCodeBlockContainerView *)view]; + } else if ([view isKindOfClass:[ENRMContainerNodeView class]]) { + ENRMCollectCodeBlockViews(((ENRMContainerNodeView *)view).segmentViews, codeBlockViews); + } + } +} + +@implementation ENRMCodeBlockWrapCursor { + NSSet *_wrapped; + NSUInteger _nextIndex; +} + +- (instancetype)initWithWrapped:(NSSet *)wrapped +{ + if (self = [super init]) { + _wrapped = wrapped; + } + return self; +} + +- (BOOL)nextIsWrapped +{ + return [_wrapped containsObject:@(_nextIndex++)]; +} + +@end + +@interface ENRMCodeBlockWrapState () +@property (atomic, copy, readwrite) NSSet *indices; +@end + +@implementation ENRMCodeBlockWrapState { + NSArray * (^_codeBlockViews)(void); + void (^_onHeightChanged)(void); +} + +- (instancetype)initWithCodeBlockViews:(NSArray * (^)(void))codeBlockViews + onHeightChanged:(void (^)(void))onHeightChanged +{ + if (self = [super init]) { + _codeBlockViews = [codeBlockViews copy]; + _onHeightChanged = [onHeightChanged copy]; + self.indices = [NSSet set]; + } + return self; +} + +- (BOOL)updateEnabled:(BOOL)enabled +{ + if (_enabled == enabled) { + return NO; + } + _enabled = enabled; + BOOL cleared = !enabled && self.indices.count > 0; + if (cleared) { + self.indices = [NSSet set]; + } + [self sync]; + return cleared; +} + +- (BOOL)markdownPropDidChangeFrom:(const std::string &)previous + to:(const std::string &)next + rendered:(NSString *)rendered +{ + if (self.indices.count == 0) { + return NO; + } + const bool echoesRendered = rendered != nil && next.compare(rendered.UTF8String) == 0; + if (echoesRendered || ENRMKeepsCodeBlockWrapState(previous, next)) { + return NO; + } + self.indices = [NSSet set]; + return YES; +} + +- (void)sync +{ + NSSet *wrapped = self.indices; + __weak ENRMCodeBlockWrapState *weakSelf = self; + [_codeBlockViews() enumerateObjectsUsingBlock:^(ENRMCodeBlockContainerView *view, NSUInteger index, BOOL *stop) { + view.onWrapToggle = ^(ENRMCodeBlockContainerView *toggled) { [weakSelf toggle:toggled]; }; + view.showsWrapButton = self->_enabled; + view.wrapped = self->_enabled && [wrapped containsObject:@(index)]; + }]; +} + +- (void)toggle:(ENRMCodeBlockContainerView *)view +{ + if (!_enabled) { + return; + } + NSUInteger index = [_codeBlockViews() indexOfObjectIdenticalTo:view]; + if (index == NSNotFound) { + return; + } + NSMutableSet *wrapped = [self.indices mutableCopy]; + if ([wrapped containsObject:@(index)]) { + [wrapped removeObject:@(index)]; + } else { + [wrapped addObject:@(index)]; + } + self.indices = wrapped; + [self sync]; + _onHeightChanged(); +} + +- (void)reset +{ + _enabled = NO; + self.indices = [NSSet set]; +} + +@end diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index d6aeac827..7b5b6a3d8 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -268,8 +268,8 @@ export interface EnrichedMarkdownTextProps extends Omit { * header. Every block starts unwrapped (horizontal scroll) and toggles on its * 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`. + * inside blockquotes and admonitions get the toggle too. The toggle's spoken + * labels come from `accessibilityLabels.codeBlock`. * * Only applies when `flavor="github"`. * @default false From 2d2927e0bb55273f445e9ea0e1b247036466ce08 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 16 Oct 2026 22:54:31 +0200 Subject: [PATCH 5/5] fix: apply inline cr --- docs-md/API_REFERENCE.md | 4 ++-- .../docs/react-native/api-reference/enriched-markdown-text.md | 4 ++-- docs/docs/user-experience/accessibility.md | 2 +- .../com/swmansion/enriched/markdown/CodeBlockWrapState.kt | 3 ++- .../swmansion/enriched/markdown/EnrichedMarkdownManager.kt | 2 +- .../java/com/swmansion/enriched/markdown/MeasurementStore.kt | 2 +- .../ios/utils/ENRMCodeBlockWrapState.h | 2 +- .../ios/utils/ENRMCodeBlockWrapState.mm | 4 +++- .../src/EnrichedMarkdownNativeComponent.ts | 2 +- .../src/native/EnrichedMarkdownText.tsx | 2 +- .../src/types/MarkdownTextProps.ts | 4 ++-- 11 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md index dc16050eb..2550ce710 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -239,11 +239,11 @@ 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. 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. The toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). macOS accepts the prop but shows no toggle. +Shows a line wrap toggle next to the copy button in each fenced code block header (`flavor="github"`); set it to `false` to hide the toggle. 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. The toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). macOS accepts the prop but shows no toggle. | Type | Default Value | Platform | | --------- | ------------- | ------------ | -| `boolean` | `false` | iOS, Android | +| `boolean` | `true` | iOS, Android | ### `enableBlockContextMenu` 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 6a3e983db..957b69283 100644 --- a/docs/docs/react-native/api-reference/enriched-markdown-text.md +++ b/docs/docs/react-native/api-reference/enriched-markdown-text.md @@ -219,9 +219,9 @@ Controls the long-press context menu on block views - fenced code blocks, tables ### `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. Needs [`flavor="github"`](#flavor); the toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). iOS and Android only; macOS accepts the prop but shows no toggle. +Shows a line wrap toggle next to the copy button in each fenced code block header; set it to `false` to hide the toggle. 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. Needs [`flavor="github"`](#flavor); the toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). iOS and Android only; macOS accepts the prop but shows no toggle. - + ### `enableLinkPreview` {#enablelinkpreview} diff --git a/docs/docs/user-experience/accessibility.md b/docs/docs/user-experience/accessibility.md index 0b0a70a90..3218744be 100644 --- a/docs/docs/user-experience/accessibility.md +++ b/docs/docs/user-experience/accessibility.md @@ -119,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 the optional wrap toggle (`enableCodeBlockWrapToggle`) speaks `codeBlock.wrap` / `codeBlock.unwrap`; iOS vends both as VoiceOver actions, 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`, while 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/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt index d1dcb3b89..6246f87a1 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt @@ -31,7 +31,8 @@ internal class CodeBlockWrapState( private val codeBlockViews: () -> Sequence, private val onHeightChanged: () -> Unit, ) { - var enabled: Boolean = false + // Matches the prop's default, for a host that never receives the prop. + var enabled: Boolean = true private set var indices: Set = emptySet() 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 e8cab5964..5dc9ce78c 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 @@ -245,7 +245,7 @@ class EnrichedMarkdownManager : view?.enableCodeBlockPress = enableCodeBlockPress } - @ReactProp(name = "enableCodeBlockWrapToggle", defaultBoolean = false) + @ReactProp(name = "enableCodeBlockWrapToggle", defaultBoolean = true) override fun setEnableCodeBlockWrapToggle( view: EnrichedMarkdown?, enableCodeBlockWrapToggle: Boolean, 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 c9a9420d3..9dfb3bbde 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 @@ -490,7 +490,7 @@ object MeasurementStore { rawMarkdown } val wrapped = - if (props.getBooleanOrDefault("enableCodeBlockWrapToggle", false)) { + if (props.getBooleanOrDefault("enableCodeBlockWrapToggle", true)) { id?.let { wrappedCodeBlocks[it] } ?: emptySet() } else { emptySet() diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h index 6ad5ca1c7..ca99ded71 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h @@ -83,7 +83,7 @@ void ENRMCollectCodeBlockViews(NSArray *segmentViews, // Pushes the wrapped set and the toggle onto the current code block views. - (void)sync; -// Back to the initial state (toggle off, nothing wrapped) for a recycled view. +// Back to the initial state (toggle on, nothing wrapped) for a recycled view. - (void)reset; @end diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm index c65aee3a4..451f27302 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm @@ -48,6 +48,8 @@ - (instancetype)initWithCodeBlockViews:(NSArray * if (self = [super init]) { _codeBlockViews = [codeBlockViews copy]; _onHeightChanged = [onHeightChanged copy]; + // Matches the prop's default. + _enabled = YES; self.indices = [NSSet set]; } return self; @@ -115,7 +117,7 @@ - (void)toggle:(ENRMCodeBlockContainerView *)view - (void)reset { - _enabled = NO; + _enabled = YES; self.indices = [NSSet set]; } diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index cd05db07a..eea51c405 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -532,7 +532,7 @@ export interface NativeProps extends ViewProps { * block headers. Each block starts unwrapped. * @default false */ - enableCodeBlockWrapToggle?: CodegenTypes.WithDefault; + 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 73e460b9e..dd5621a31 100644 --- a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx @@ -145,7 +145,7 @@ export const EnrichedMarkdownText = ({ onCopyPress, onLatexError, onCodeBlockPress, - enableCodeBlockWrapToggle = false, + enableCodeBlockWrapToggle = true, enableBlockContextMenu = true, enableLinkPreview, selectable = true, diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index 7b5b6a3d8..c198d8d0b 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -265,14 +265,14 @@ export interface EnrichedMarkdownTextProps extends Omit { 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 + * header; set `false` to hide it. Every block starts unwrapped (horizontal scroll) and toggles on its * 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 get the toggle too. The toggle's spoken * labels come from `accessibilityLabels.codeBlock`. * * Only applies when `flavor="github"`. - * @default false + * @default true * @platform ios, android */ enableCodeBlockWrapToggle?: boolean;