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 d64f1d10d..b21dc41af 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -282,6 +282,14 @@ Works in both flavors: `flavor="github"` fires on the container-based code block /> ``` +### `enableCodeBlockWrapToggle` + +Adds a line wrap toggle next to the copy button in each fenced code block header (`flavor="github"`). Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. 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. @@ -782,6 +790,10 @@ interface AccessibilityLabels { math?: { equation?: string; // "Math: {latex}" }; + codeBlock?: { // enableCodeBlockWrapToggle + wrap?: string; // "Wrap lines" + unwrap?: string; // "Disable line wrap" + }; rotor?: { // iOS only headings?: string; // "Headings" links?: string; // "Links" diff --git a/docs/docs/react-native/api-reference/enriched-markdown-text.md b/docs/docs/react-native/api-reference/enriched-markdown-text.md index 0a84cc9df..767402bca 100644 --- a/docs/docs/react-native/api-reference/enriched-markdown-text.md +++ b/docs/docs/react-native/api-reference/enriched-markdown-text.md @@ -217,6 +217,12 @@ Controls the long-press context menu on block views - fenced code blocks, tables iOS, Android, and macOS only - the block context menu is a native interaction, and the web build renders blocks without one.} /> +### `enableCodeBlockWrapToggle` + +Adds a line wrap toggle next to the copy button in each fenced code block header. Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. The state belongs to the block's position in the document: it survives streaming appends and resets when the Markdown is replaced rather than appended to. Code blocks inside blockquotes and admonitions have no toggle and keep scrolling. Needs [`flavor="github"`](#flavor); the toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). iOS and Android only. + + + ### `enableLinkPreview` {#enablelinkpreview} Controls the native link preview on long press. It is on by default, and supplying [`onLinkLongPress`](#onlinklongpress) turns it off - your handler owns the long press instead. That override cannot be undone from this prop: with `onLinkLongPress` set, even an explicit `enableLinkPreview={true}` is discarded. Setting [`selectable={false}`](#selectable) also suppresses the preview. @@ -491,6 +497,11 @@ interface AccessibilityLabels { math?: { equation?: string; // {latex} is the equation source }; + codeBlock?: { + // the enableCodeBlockWrapToggle header button + wrap?: string; + unwrap?: string; + }; rotor?: { // iOS only, Android has no rotor headings?: string; diff --git a/docs/docs/user-experience/accessibility.md b/docs/docs/user-experience/accessibility.md index aa8e4db92..0b0a70a90 100644 --- a/docs/docs/user-experience/accessibility.md +++ b/docs/docs/user-experience/accessibility.md @@ -61,6 +61,8 @@ packages ship. */} | `blockquote.nestedQuote` | `"Nested blockquote"` | iOS + Android | | `table.row` | `"Row {n}: {content}"` | iOS + Android | | `math.equation` | `"Math: {latex}"` | iOS + Android | +| `codeBlock.wrap` | `"Wrap lines"` | iOS + Android | +| `codeBlock.unwrap` | `"Disable line wrap"` | iOS + Android | | `rotor.headings` / `rotor.links` / `rotor.images` | `"Headings"` / `"Links"` / `"Images"` | iOS only | Placeholders: `{n}` (1-based index), `{content}` (comma-joined cell texts), `{latex}` (equation source). Preserve placeholder names exactly in translations. Defaults use the no-plural cardinal form so a single template works in every language. @@ -117,7 +119,7 @@ Four sharp edges are worth knowing: - **Android** has no rotor concept, so `accessibilityLabels.rotor.*` is ignored there. - **Task list items** have no accessibility handling in the React Native package on either platform: a checkbox is read as plain list text, with no checked state announced and no toggle action exposed. There is no label key for it. - **Admonitions** carry no accessibility role; they are announced as the blockquote they are built from. -- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label, and iOS vends a VoiceOver copy action, but the block itself has no dedicated role. +- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label and the optional wrap toggle (`enableCodeBlockWrapToggle`) speaks `codeBlock.wrap` / `codeBlock.unwrap`; iOS vends both as VoiceOver actions, but the block itself has no dedicated role. - The platform tabs above describe the renderer. The **editor** speaks neither heading level nor task state (see [Editor accessibility](#editor-accessibility)). ## Reference diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index 8ac85e9d8..987f30058 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -53,6 +53,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), @@ -244,12 +253,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 ++currentRenderId renderPending = true @@ -466,6 +521,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 { @@ -604,6 +662,7 @@ class EnrichedMarkdown( view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1 } } + syncCodeBlockWrapState() if (width > 0) { val heightBefore = computeSegmentsTotalHeight() diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index 74ce4a8f3..41f308f6d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -63,7 +63,8 @@ class EnrichedMarkdownManager : view.setOnTaskListItemPressCallback { taskIndex, checked, itemText -> val newChecked = !checked val updatedMarkdown = TaskListToggleUtils.toggleAtIndex(view.currentMarkdown, taskIndex, newChecked) - view.setMarkdownContent(updatedMarkdown) + // A checkbox flip keeps every code block where it was, so keep their wrap state too. + view.setMarkdownContent(updatedMarkdown, keepWrapState = true) view.commitProps() emitTaskListItemPress(view, taskIndex, newChecked, itemText) } @@ -330,6 +331,14 @@ class EnrichedMarkdownManager : view?.enableCodeBlockPress = enableCodeBlockPress } + @ReactProp(name = "enableCodeBlockWrapToggle", defaultBoolean = false) + override fun setEnableCodeBlockWrapToggle( + view: EnrichedMarkdown?, + enableCodeBlockWrapToggle: Boolean, + ) { + view?.enableCodeBlockWrapToggle = enableCodeBlockWrapToggle + } + @ReactProp(name = "lineBreakStrategyIOS") override fun setLineBreakStrategyIOS( view: EnrichedMarkdown?, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt index 09d723563..97656c5f0 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -95,6 +95,9 @@ object MeasurementStore { private val streamingCodeBlockModes = ConcurrentHashMap() + // Top-level code blocks the user wrapped, by document-order index (see EnrichedMarkdown). + private val wrappedCodeBlocks = ConcurrentHashMap>() + private fun resolveFontScalingSettings( viewId: Int?, props: ReadableMap?, @@ -147,6 +150,15 @@ object MeasurementStore { data.remove(id) numberOfLinesByViewId.remove(id) ellipsizeModeByViewId.remove(id) + wrappedCodeBlocks.remove(id) + } + + fun updateWrappedCodeBlocks( + viewId: Int, + indices: Set, + ) { + if (indices.isEmpty()) wrappedCodeBlocks.remove(viewId) else wrappedCodeBlocks[viewId] = indices + data.remove(viewId) } fun invalidate(id: Int) { @@ -491,7 +503,16 @@ object MeasurementStore { } else { rawMarkdown } - val propsHash = computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + val wrapped = + if (props.getBooleanOrDefault("enableCodeBlockWrapToggle", false)) { + id?.let { wrappedCodeBlocks[it] } ?: emptySet() + } else { + emptySet() + } + // A Set's hashCode sums its elements, so {} and {0} would collide; the sorted list's doesn't. + val propsHash = + 31 * computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + + wrapped.sorted().hashCode() // Streaming shortcut: reuse cached size when the filtered content and // width are unchanged. When the filter output changes (e.g. a table @@ -589,6 +610,7 @@ object MeasurementStore { val lastIndex = renderedSegments.lastIndex var totalHeightPx = 0f var maxContentWidthPx = 0f + var codeBlockIndex = 0 for ((index, segment) in renderedSegments.withIndex()) { val isLastSegment = index == lastIndex @@ -629,7 +651,9 @@ object MeasurementStore { is RenderedSegment.CodeBlock -> { totalHeightPx += style.codeBlockStyle.marginTop - totalHeightPx += CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width) + totalHeightPx += + CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width, codeBlockIndex in wrapped) + codeBlockIndex++ maxContentWidthPx = width if (includeBottomMargin) { totalHeightPx += style.codeBlockStyle.marginBottom diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/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 4dcaf4d1c..f677c50fd 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt @@ -146,6 +146,7 @@ object SegmentViewCreators { config: SegmentViewConfig, ) = CodeBlockContainerView(config.context, config.style).apply { dynamicProps = config.dynamicProps + accessibilityLabels = config.accessibilityLabels applyCodeBlockNode(segment.node) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index b4d3cdc5f..40ef07bff 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -181,6 +181,7 @@ fun parseAccessibilityLabels(value: ReadableMap?): AccessibilityLabels { val blockquote = value.getMap("blockquote") val table = value.getMap("table") val math = value.getMap("math") + val codeBlock = value.getMap("codeBlock") val rotor = value.getMap("rotor") return AccessibilityLabels( bulletPoint = list?.getString("bulletPoint") ?: "", @@ -191,6 +192,8 @@ fun parseAccessibilityLabels(value: ReadableMap?): AccessibilityLabels { nestedBlockquote = blockquote?.getString("nestedQuote") ?: "", tableRow = table?.getString("row") ?: "", mathEquation = math?.getString("equation") ?: "", + codeBlockWrap = codeBlock?.getString("wrap") ?: "", + codeBlockUnwrap = codeBlock?.getString("unwrap") ?: "", rotorHeadings = rotor?.getString("headings") ?: "", rotorLinks = rotor?.getString("links") ?: "", rotorImages = rotor?.getString("images") ?: "", diff --git a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h index daca4ce8b..fd78a9446 100644 --- a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +++ b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h @@ -47,6 +47,8 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownProps &props) { serializedProps["maxFontSizeMultiplier"] = props.maxFontSizeMultiplier; serializedProps["allowTrailingMargin"] = props.allowTrailingMargin; serializedProps["streamingAnimation"] = props.streamingAnimation; + // Wrapped code blocks measure at the block width; the indexes live in MeasurementStore. + serializedProps["enableCodeBlockWrapToggle"] = props.enableCodeBlockWrapToggle; serializedProps["enableImageSourceResolution"] = props.enableImageSourceResolution; serializedProps["imageSourcesRevision"] = props.imageSourcesRevision; diff --git a/packages/react-native-enriched-markdown/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 9cd82174a..4d92d0c84 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -98,6 +98,7 @@ - (void)emitContextMenuItemPress:(NSString *)itemText selectedText:(NSString *)selectedText selectionStart:(NSUInteger)selectionStart selectionEnd:(NSUInteger)selectionEnd; +@property (atomic, copy, readwrite) NSSet *wrappedCodeBlockIndices; @end #if !TARGET_OS_OSX @@ -138,6 +139,7 @@ @implementation EnrichedMarkdown { ENRMCodeBlockStreamingMode _codeBlockStreamingMode; // Trailing code block whose closing fence hasn't streamed in yet, if any. ENRMCodeBlockSegment *_pendingCodeBlockSegment; + BOOL _enableCodeBlockWrapToggle; size_t _renderedStyleFingerprint; size_t _pendingStyleFingerprint; @@ -244,6 +246,7 @@ - (instancetype)initWithFrame:(CGRect)frame _enableLinkPreview = YES; _enableTaskListItemToggle = YES; _enableImagePress = NO; + self.wrappedCodeBlockIndices = [NSSet set]; _dynamicBlockProps = [[ENRMDynamicBlockProps alloc] init]; _linkContextMenus = [[ENRMLinkContextMenus alloc] init]; __weak EnrichedMarkdown *weakMenuSelf = self; @@ -763,6 +766,8 @@ - (void)pushAccessibilityLabelsToSegments ((TableContainerView *)segment).accessibilityLabels = _accessibilityLabels; } else if ([segment isKindOfClass:[ENRMBlockquoteContainerView class]]) { ((ENRMBlockquoteContainerView *)segment).accessibilityLabels = _accessibilityLabels; + } else if ([segment isKindOfClass:[ENRMCodeBlockContainerView class]]) { + ((ENRMCodeBlockContainerView *)segment).accessibilityLabels = _accessibilityLabels; } #if ENRICHED_MARKDOWN_MATH else if ([segment isKindOfClass:[ENRMMathContainerView class]]) { @@ -1007,6 +1012,7 @@ - (void)applyRenderedSegments:(NSArray *)renderedSegments self->_pendingCodeBlockSegment != nil && i == self->_segmentViews.count - 1; } }]; + [self syncCodeBlockWrapState]; CGRect contentBounds = [self contentBounds]; if (contentBounds.size.width > 0) { @@ -1142,6 +1148,7 @@ - (ENRMCodeBlockContainerView *)createCodeBlockViewForSegment:(ENRMCodeBlockSegm { ENRMCodeBlockContainerView *codeBlockView = [[ENRMCodeBlockContainerView alloc] initWithConfig:_config]; codeBlockView.dynamicProps = _dynamicBlockProps; + codeBlockView.accessibilityLabels = _accessibilityLabels; __weak EnrichedMarkdown *weakSelf = self; codeBlockView.onCopyPress = ^(NSString *code, NSString *language) { @@ -1154,12 +1161,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) @@ -1212,6 +1260,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; + } } NSDictionary *mediaOverrides = ENRMMediaOverridesFromProps(newViewProps); @@ -1346,6 +1405,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; @@ -1413,6 +1485,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 = @@ -1534,6 +1610,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 43b3a3796..331b2bcd6 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm @@ -768,6 +768,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.table.row.c_str()]; _accessibilityLabels.mathEquation = [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.math.equation.c_str()]; + _accessibilityLabels.codeBlockWrap = + [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.codeBlock.wrap.c_str()]; + _accessibilityLabels.codeBlockUnwrap = + [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.codeBlock.unwrap.c_str()]; _accessibilityLabels.rotorHeadings = [[NSString alloc] initWithUTF8String:newViewProps.accessibilityLabels.rotor.headings.c_str()]; _accessibilityLabels.rotorLinks = diff --git a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h index 6aa96222b..8cb91b8e9 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -233,11 +233,15 @@ static inline CGSize ENRMMeasureMarkdownViewFree(const PropsT &typedProps, CGFlo * same condition, tables report full width. Finishes with `measureSize:`'s * pixel rounding (width capped at maxWidth) and its * (maxWidth × system-16 line height) fallback for empty/failed content. + * `wrappedCodeBlocks` holds the document-order indexes of top-level code blocks + * the user wrapped (EnrichedMarkdown.wrappedCodeBlockIndices); those measure at + * the block width instead of their unwrapped height. */ template static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedProps, CGFloat maxWidth, CGFloat fontScale, CGFloat pointScaleFactor, - NSWritingDirection resolvedLayoutDirection) + NSWritingDirection resolvedLayoutDirection, + NSSet *wrappedCodeBlocks = nil) { @autoreleasepool { CGSize fallback = CGSizeMake(maxWidth, UIFontLineHeight([UIFont systemFontOfSize:16.0])); @@ -304,6 +308,7 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro CGFloat yOffset = 0.0; CGFloat maxContentWidth = 0.0; const NSUInteger lastIndex = segments.count - 1; + NSUInteger codeBlockIndex = 0; for (NSUInteger i = 0; i < segments.count; i++) { ENRMRenderedSegment *segment = segments[i]; @@ -337,8 +342,13 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro } } else if (segment.kind == ENRMSegmentKindCodeBlock && segment.codeBlockSegment) { yOffset += config.codeBlockMarginTop; - yOffset += [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:segment.codeBlockSegment.codeBlockNode - config:config]; + MarkdownASTNode *codeBlockNode = segment.codeBlockSegment.codeBlockNode; + yOffset += [wrappedCodeBlocks containsObject:@(codeBlockIndex)] + ? [ENRMCodeBlockContainerView measureWrappedHeightForCodeBlockNode:codeBlockNode + config:config + maxWidth:maxWidth] + : [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:codeBlockNode config:config]; + codeBlockIndex++; maxContentWidth = maxWidth; if (shouldAddBottomMargin) { yOffset += config.codeBlockMarginBottom; diff --git a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm index ac9b15897..3405c6ea5 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm @@ -50,6 +50,17 @@ } } +// Order-independent fingerprint of the wrapped set for the measurement cache +// key: 0 for none, and {0} hashes apart from {} (HashUtils::combine is not 0 at 0). +static size_t ENRMWrappedCodeBlocksFingerprint(NSSet *wrappedCodeBlocks) +{ + size_t h = 0; + for (NSNumber *index in [wrappedCodeBlocks.allObjects sortedArrayUsingSelector:@selector(compare:)]) { + HashUtils::hash_one(h, index.unsignedLongValue); + } + return h; +} + Size EnrichedMarkdownShadowNode::measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const { @@ -62,13 +73,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 c69302c7c..dbd7e959c 100644 --- a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h +++ b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h @@ -47,6 +47,8 @@ struct MeasurementCacheKey { bool md4cFlagsAdmonitions; size_t styleFingerprint; size_t linkPillContentFingerprint; + // Code blocks the user wrapped (view state, not props); 0 when none. + size_t wrappedCodeBlocksFingerprint; CGFloat fontScale; MarkdownFlavor flavor; std::string lineBreakStrategyIOS; @@ -61,15 +63,16 @@ struct MeasurementCacheKey { return std::tie(markdown, maxWidth, allowTrailingMargin, allowFontScaling, maxFontSizeMultiplier, md4cFlagsUnderline, md4cFlagsSuperscript, md4cFlagsSubscript, md4cFlagsHighlight, md4cFlagsLatexMath, md4cFlagsHardSoftBreaks, md4cFlagsPreserveBlankLines, md4cFlagsAdmonitions, - styleFingerprint, linkPillContentFingerprint, fontScale, flavor, lineBreakStrategyIOS, - writingDirection, numberOfLines, ellipsizeMode, linkRegexKey, inlineCodeLinkRegexKey) == + styleFingerprint, linkPillContentFingerprint, wrappedCodeBlocksFingerprint, fontScale, flavor, + lineBreakStrategyIOS, writingDirection, numberOfLines, ellipsizeMode, linkRegexKey, + inlineCodeLinkRegexKey) == std::tie(other.markdown, other.maxWidth, other.allowTrailingMargin, other.allowFontScaling, other.maxFontSizeMultiplier, other.md4cFlagsUnderline, other.md4cFlagsSuperscript, other.md4cFlagsSubscript, other.md4cFlagsHighlight, other.md4cFlagsLatexMath, other.md4cFlagsHardSoftBreaks, other.md4cFlagsPreserveBlankLines, other.md4cFlagsAdmonitions, - other.styleFingerprint, other.linkPillContentFingerprint, other.fontScale, other.flavor, - other.lineBreakStrategyIOS, other.writingDirection, other.numberOfLines, other.ellipsizeMode, - other.linkRegexKey, other.inlineCodeLinkRegexKey); + other.styleFingerprint, other.linkPillContentFingerprint, other.wrappedCodeBlocksFingerprint, + other.fontScale, other.flavor, other.lineBreakStrategyIOS, other.writingDirection, + other.numberOfLines, other.ellipsizeMode, other.linkRegexKey, other.inlineCodeLinkRegexKey); } }; @@ -92,6 +95,7 @@ struct MeasurementCacheKeyHash { HashUtils::hash_one(h, key.md4cFlagsAdmonitions); HashUtils::hash_one(h, key.styleFingerprint); HashUtils::hash_one(h, key.linkPillContentFingerprint); + HashUtils::hash_one(h, key.wrappedCodeBlocksFingerprint); HashUtils::hash_one(h, key.fontScale); HashUtils::hash_one(h, static_cast(key.flavor)); HashUtils::hash_one(h, key.lineBreakStrategyIOS); @@ -196,7 +200,7 @@ template inline std::string buildLinkRecognitionCacheKey(c template inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFloat maxWidth, CGFloat fontScale, - MarkdownFlavor flavor) + MarkdownFlavor flavor, size_t wrappedCodeBlocksFingerprint = 0) { return MeasurementCacheKey{ .markdown = props.markdown, @@ -215,6 +219,7 @@ inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFl .styleFingerprint = computeStyleFingerprint(props.markdownStyle) ^ ENRMImageSourcesFingerprint(props) ^ ENRMMediaPropsFingerprint(props), .linkPillContentFingerprint = computeLinkPillContentFingerprint(props.linkPillContent), + .wrappedCodeBlocksFingerprint = wrappedCodeBlocksFingerprint, .fontScale = fontScale, .flavor = flavor, .lineBreakStrategyIOS = props.lineBreakStrategyIOS, diff --git a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h index 48defb1bf..523809baa 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h +++ b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h @@ -87,14 +87,17 @@ static inline bool ENRMPropsNeedExactStreamingMeasurement(const PropsT &oldProps * `ENRMMeasureSegmentedMarkdownViewFree`); the resolved view is still handed * to the block for strategies that can use it. `fontScale` handed to the * block is the real multiplier even when streaming skips the cache — - * rendering must always use it. + * rendering must always use it. `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; @@ -120,7 +123,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); @@ -130,7 +133,7 @@ ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr CGSize size = measureUncached(view, maxWidth, fontScale); if (shouldUseMeasurementCache && !typedProps.markdown.empty()) { - auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor); + auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor, wrappedCodeBlocksFingerprint); MeasurementCache::shared().set(cacheKey, {size.width, size.height}); } 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/package.json b/packages/react-native-enriched-markdown/package.json index 9dae0dea3..6c7ad2168 100644 --- a/packages/react-native-enriched-markdown/package.json +++ b/packages/react-native-enriched-markdown/package.json @@ -1,6 +1,6 @@ { "name": "react-native-enriched-markdown", - "version": "1.2.0-t3.11", + "version": "1.2.0-t3.12", "description": "Markdown-based Rich Text solution for React Native", "main": "./lib/module/index", "module": "./lib/module/index", diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index 257f5d6a7..4b9346ffe 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -360,6 +360,10 @@ export interface AccessibilityLabelsTableProps { export interface AccessibilityLabelsMathProps { equation: string; } +export interface AccessibilityLabelsCodeBlockProps { + wrap: string; + unwrap: string; +} export interface AccessibilityLabelsRotorProps { headings: string; links: string; @@ -370,6 +374,7 @@ export interface AccessibilityLabelsProps { blockquote: Readonly; table: Readonly; math: Readonly; + codeBlock: Readonly; rotor: Readonly; } @@ -597,6 +602,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 001c380e8..1a7590886 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts @@ -361,6 +361,10 @@ export interface AccessibilityLabelsTableProps { export interface AccessibilityLabelsMathProps { equation: string; } +export interface AccessibilityLabelsCodeBlockProps { + wrap: string; + unwrap: string; +} export interface AccessibilityLabelsRotorProps { headings: string; links: string; @@ -371,6 +375,7 @@ export interface AccessibilityLabelsProps { blockquote: Readonly; table: Readonly; math: Readonly; + codeBlock: Readonly; rotor: Readonly; } diff --git a/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts b/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts index bdb4857a9..04884ba08 100644 --- a/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts +++ b/packages/react-native-enriched-markdown/src/accessibilityLabelDefaults.ts @@ -25,6 +25,10 @@ export const DEFAULT_ACCESSIBILITY_LABELS: ResolvedAccessibilityLabels = { math: { equation: 'Math: {latex}', }, + codeBlock: { + wrap: 'Wrap lines', + unwrap: 'Disable line wrap', + }, rotor: { headings: 'Headings', links: 'Links', @@ -72,6 +76,13 @@ export function resolveAccessibilityLabels( equation: labels?.math?.equation ?? DEFAULT_ACCESSIBILITY_LABELS.math.equation, }, + codeBlock: { + wrap: + labels?.codeBlock?.wrap ?? DEFAULT_ACCESSIBILITY_LABELS.codeBlock.wrap, + unwrap: + labels?.codeBlock?.unwrap ?? + DEFAULT_ACCESSIBILITY_LABELS.codeBlock.unwrap, + }, rotor: { headings: labels?.rotor?.headings ?? DEFAULT_ACCESSIBILITY_LABELS.rotor.headings, diff --git a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx index 4d92f7abf..5d602fe08 100644 --- a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx @@ -151,6 +151,7 @@ export const EnrichedMarkdownText = ({ onCopyPress, onLatexError, onCodeBlockPress, + enableCodeBlockWrapToggle = false, enableBlockContextMenu = true, enableLinkPreview, selectable = true, @@ -489,6 +490,7 @@ export const EnrichedMarkdownText = ({ @@ -500,6 +502,7 @@ export const EnrichedMarkdownText = ({ ); } diff --git a/packages/react-native-enriched-markdown/src/types/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 73233a3ea..26060e2ec 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -299,6 +299,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 0ffb2c7ed..111128d58 100644 --- a/packages/react-native-enriched-markdown/src/web/nativeProps.ts +++ b/packages/react-native-enriched-markdown/src/web/nativeProps.ts @@ -37,6 +37,7 @@ export const NATIVE_ONLY_PROP_NAMES: Record = { onCopyPress: true, onLatexError: true, enableBlockContextMenu: true, + enableCodeBlockWrapToggle: true, enableLinkPreview: true, selectionHandleColor: true, allowFontScaling: true,