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/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..2550ce710 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` + +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` | `true` | 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..957b69283 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` + +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} 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..3218744be 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`, 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/__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..6246f87a1 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/CodeBlockWrapState.kt @@ -0,0 +1,108 @@ +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, +) { + // Matches the prop's default, for a host that never receives the prop. + var enabled: Boolean = true + 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 c12937c50..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 @@ -50,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 @@ -141,11 +145,30 @@ class EnrichedMarkdown( dynamicProps.enableCodeBlockPress = value } + private val codeBlockWrapState = + CodeBlockWrapState(viewTag = { id }, codeBlockViews = ::codeBlockViews, onHeightChanged = ::onImageLayoutChanged) + + var enableCodeBlockWrapToggle: Boolean + get() = codeBlockWrapState.enabled + set(value) { + if (codeBlockWrapState.setEnabled(value)) onImageLayoutChanged() + } + init { segmentViewFactory = RootFactory() } - fun setMarkdownContent(markdown: String) { + // 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, + isTaskToggle: Boolean = false, + ) { + // 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 @@ -360,6 +383,9 @@ class EnrichedMarkdown( segmentViews.filterIsInstance().forEach { it.accessibilityLabels = labels } + codeBlockViews().forEach { + it.accessibilityLabels = labels + } mathContainerClass?.let { mathClass -> segmentViews.filter { mathClass.isInstance(it) }.forEach { view -> runCatching { @@ -458,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) @@ -469,6 +496,7 @@ class EnrichedMarkdown( view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1 } } + codeBlockWrapState.sync() if (width > 0) { val heightBefore = computeSegmentsTotalHeight() @@ -480,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 5dd58285c..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 @@ -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, isTaskToggle = true) view.commitProps() emitTaskListItemPress(view, taskIndex, newChecked, itemText) } @@ -244,6 +245,14 @@ class EnrichedMarkdownManager : view?.enableCodeBlockPress = enableCodeBlockPress } + @ReactProp(name = "enableCodeBlockWrapToggle", defaultBoolean = true) + 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..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 @@ -89,6 +89,9 @@ object MeasurementStore { private val streamingCodeBlockModes = ConcurrentHashMap() + // Code blocks the user wrapped, by document-order index across nesting (see CodeBlockWrapState). + 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", true)) { + 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 + val wrapCursor = CodeBlockWrapCursor(wrapped) for ((index, segment) in renderedSegments.withIndex()) { val isLastSegment = index == lastIndex @@ -594,7 +616,8 @@ 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, wrapCursor.nextIsWrapped()) maxContentWidthPx = width if (includeBottomMargin) { totalHeightPx += style.codeBlockStyle.marginBottom @@ -603,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/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/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 cba082576..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 @@ -25,6 +25,7 @@ import android.widget.HorizontalScrollView import android.widget.ImageView import androidx.appcompat.widget.AppCompatImageButton import androidx.appcompat.widget.AppCompatTextView +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 @@ -35,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 @@ -47,8 +49,9 @@ import kotlin.math.roundToInt * * measureCodeBlockNodeHeight must stay in sync with the view: it builds the * same styled text, paint, and header metrics, so the height reported to - * Yoga matches the laid-out height. Since lines never wrap, the height is - * independent of the available width. + * Yoga matches the laid-out height. Unless the block is wrapped (the optional + * header wrap toggle), lines never wrap and the height is independent of the + * available width. */ class CodeBlockContainerView( context: Context, @@ -69,9 +72,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 +97,29 @@ class CodeBlockContainerView( rebuildCodeText() } + // Header wrap toggle next to copy; CodeBlockWrapState sets it from enableCodeBlockWrapToggle. + 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. Only + // CodeBlockWrapState writes it; the header toggle just asks through onWrapToggle. + var wrapped: Boolean = false + set(value) { + if (field == value) return + field = value + updateWrapButtonAppearance() + if (value) scrollView.scrollTo(0, 0) + requestLayout() + } + + // Fired when the header toggle is pressed; the host flips [wrapped] and re-measures. + var onWrapToggle: ((CodeBlockContainerView) -> Unit)? = null + private val textView = AppCompatTextView(context).apply { includeFontPadding = false @@ -142,6 +177,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 +201,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 +223,18 @@ 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() { + onWrapToggle?.invoke(this) } fun applyCodeBlockNode(node: MarkdownASTNode) { @@ -206,15 +264,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 +315,8 @@ class CodeBlockContainerView( buttonLeft + copyButton.measuredWidth, buttonTop + copyButton.measuredHeight, ) + 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 +357,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,6 +413,49 @@ class CodeBlockContainerView( override fun getOpacity() = PixelFormat.TRANSLUCENT } + // 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.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 + invalidateSelf() + } + + override fun draw(canvas: Canvas) { + val saved = canvas.save() + canvas.translate(bounds.left.toFloat(), bounds.top.toFloat()) + glyph.draw(canvas, bounds.width().toFloat(), paint) + canvas.restoreToCount(saved) + } + + override fun getIntrinsicWidth() = size + + override fun getIntrinsicHeight() = size + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity() = PixelFormat.TRANSLUCENT + } + companion object { private const val HEADER_LABEL_SCALE = 0.85f @@ -389,14 +505,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 +522,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/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/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/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/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/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 99ea444f3..1d4df073d 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h @@ -20,6 +20,12 @@ NS_ASSUME_NONNULL_BEGIN /// Backed by a single lock-free atomic, so the pair can't tear. Returns /// CGSizeZero until the first layout is committed. - (CGSize)lastCommittedLayoutSize; + +/// Document-order indexes, 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 5a0b7e3e6..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" @@ -133,6 +134,7 @@ @implementation EnrichedMarkdown { ENRMCodeBlockStreamingMode _codeBlockStreamingMode; // Trailing code block whose closing fence hasn't streamed in yet, if any. ENRMCodeBlockSegment *_pendingCodeBlockSegment; + ENRMCodeBlockWrapState *_codeBlockWrapState; size_t _renderedStyleFingerprint; size_t _pendingStyleFingerprint; @@ -217,6 +219,21 @@ - (instancetype)initWithFrame:(CGRect)frame _enableLinkPreview = YES; _enableTaskListItemToggle = YES; _enableImagePress = NO; + __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; @@ -691,6 +708,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 +924,7 @@ - (void)applyRenderedSegments:(NSArray *)renderedSegments self->_pendingCodeBlockSegment != nil && i == self->_segmentViews.count - 1; } }]; + [_codeBlockWrapState sync]; CGRect contentBounds = [self contentBounds]; if (contentBounds.size.width > 0) { @@ -1040,6 +1060,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) { @@ -1058,6 +1079,11 @@ - (ENRMCodeBlockContainerView *)createCodeBlockViewForSegment:(ENRMCodeBlockSegm return codeBlockView; } +- (NSSet *)wrappedCodeBlockIndices +{ + return _codeBlockWrapState.indices; +} + - (void)animateBlockViewIfNeeded:(RCTUIView *)view { if (!_streamingAnimation) @@ -1110,6 +1136,13 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & BOOL markdownChanged = oldViewProps.markdown != newViewProps.markdown; if (markdownChanged) { _dirtyFlags |= ENRMDirtyRender; + // A task toggle renders its result locally before JS echoes it back as a + // prop, so the rendered document is handed over for the echo check. + if ([_codeBlockWrapState markdownPropDidChangeFrom:oldViewProps.markdown + to:newViewProps.markdown + rendered:_cachedMarkdown]) { + _dirtyFlags |= ENRMDirtyForceHeight; + } } if (_config == nil) { @@ -1211,6 +1244,11 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _dynamicBlockProps.enableBlockContextMenu = newViewProps.enableBlockContextMenu; _dynamicBlockProps.enableCodeBlockPress = newViewProps.enableCodeBlockPress; + if ([_codeBlockWrapState updateEnabled:newViewProps.enableCodeBlockWrapToggle]) { + [self setNeedsLayout]; + [self requestHeightUpdate]; + } + if (newViewProps.streamingAnimation != oldViewProps.streamingAnimation) { _streamingAnimation = newViewProps.streamingAnimation; _dirtyFlags |= ENRMDirtyForceHeight | ENRMDirtyRender; @@ -1278,6 +1316,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 +1426,7 @@ - (void)prepareForRecycle _tableStreamingMode = ENRMTableStreamingModeProgressive; _codeBlockStreamingMode = ENRMCodeBlockStreamingModeProgressive; _pendingCodeBlockSegment = nil; + [_codeBlockWrapState reset]; _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..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,11 +232,16 @@ 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, 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, CGFloat pointScaleFactor, - NSWritingDirection resolvedLayoutDirection) + NSWritingDirection resolvedLayoutDirection, + NSSet *wrappedCodeBlocks = nil) { @autoreleasepool { CGSize fallback = CGSizeMake(maxWidth, UIFontLineHeight([UIFont systemFontOfSize:16.0])); @@ -294,6 +300,7 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro CGFloat yOffset = 0.0; CGFloat maxContentWidth = 0.0; const NSUInteger lastIndex = segments.count - 1; + ENRMCodeBlockWrapCursor *wrapCursor = [[ENRMCodeBlockWrapCursor alloc] initWithWrapped:wrappedCodeBlocks]; for (NSUInteger i = 0; i < segments.count; i++) { ENRMRenderedSegment *segment = segments[i]; @@ -320,8 +327,12 @@ 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 += [wrapCursor nextIsWrapped] + ? [ENRMCodeBlockContainerView measureWrappedHeightForCodeBlockNode:codeBlockNode + config:config + maxWidth:maxWidth] + : [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:codeBlockNode config:config]; maxContentWidth = maxWidth; if (shouldAddBottomMargin) { yOffset += config.codeBlockMarginBottom; @@ -333,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/internals/EnrichedMarkdownShadowNode.mm b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm index 056225f54..701f52f92 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 cache key; 0 for none, +// and {0} still hashes apart from {}. +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/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 e17d3dfb9..4afebd0dc 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,25 @@ 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; +// 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. Only +// ENRMCodeBlockWrapState writes it; the header toggle just asks through onWrapToggle. +@property (nonatomic, assign) BOOL wrapped; + +// 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 // disabled, while the header stays visible. @property (nonatomic, assign) BOOL pending; @@ -33,6 +53,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..ad6a4e010 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 + _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]; + // The language label's room shrinks by one button. + [self setNeedsDisplay]; +} + +- (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) { + // Restore the unwrapped width for the scroll extent; 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 +{ + 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]; @@ -533,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]; } } @@ -576,9 +755,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 +822,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 +845,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/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/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/ios/utils/ENRMCodeBlockWrapState.h b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.h new file mode 100644 index 000000000..ca99ded71 --- /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 on, 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..451f27302 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockWrapState.mm @@ -0,0 +1,124 @@ +#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]; + // Matches the prop's default. + _enabled = YES; + 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 = YES; + self.indices = [NSSet set]; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m b/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m index d15bfbdd8..7ddd38954 100644 --- a/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m +++ b/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m @@ -137,7 +137,7 @@ BOOL handleTaskListTap(ENRMPlatformTextView *textView, ENRMTapRecognizer *recogn NSTextCheckingResult *match = matches[targetIndex]; NSRange prefixRange = [match rangeAtIndex:1]; NSString *prefix = [markdown substringWithRange:prefixRange]; - NSString *replacement = [NSString stringWithFormat:@"%@[%@]", prefix, checked ? @" " : @"x"]; + NSString *replacement = [NSString stringWithFormat:@"%@[%@]", prefix, checked ? @"x" : @" "]; NSMutableString *result = [markdown mutableCopy]; [result replaceCharactersInRange:match.range withString:replacement]; diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index 32ff76827..eea51c405 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..dd5621a31 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 = true, 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..c198d8d0b 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; 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 true + * @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,