Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-md/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 12 additions & 0 deletions docs-md/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ Works in both flavors: `flavor="github"` fires on the container-based code block
/>
```

### `enableCodeBlockWrapToggle`

Adds a line wrap toggle next to the copy button in each fenced code block header (`flavor="github"`). Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. The state belongs to the block's position in the document: it survives streaming appends and resets when the markdown is replaced rather than appended to. Code blocks inside blockquotes and admonitions have no toggle and keep scrolling. The toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels).

| Type | Default Value | Platform |
| --------- | ------------- | ------------ |
| `boolean` | `false` | iOS, Android |

### `enableBlockContextMenu`

Controls the long-press copy popup on code blocks, tables, block math, and blockquotes/admonitions.
Expand Down Expand Up @@ -782,6 +790,10 @@ interface AccessibilityLabels {
math?: {
equation?: string; // "Math: {latex}"
};
codeBlock?: { // enableCodeBlockWrapToggle
wrap?: string; // "Wrap lines"
unwrap?: string; // "Disable line wrap"
};
rotor?: { // iOS only
headings?: string; // "Headings"
links?: string; // "Links"
Expand Down
11 changes: 11 additions & 0 deletions docs/docs/react-native/api-reference/enriched-markdown-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ Controls the long-press context menu on block views - fenced code blocks, tables

<LivePreview src={EnableBlockContextMenuSrc} unavailable unavailableReason={<>iOS, Android, and macOS only - the block context menu is a native interaction, and the web build renders blocks without one.</>} />

### `enableCodeBlockWrapToggle`

Adds a line wrap toggle next to the copy button in each fenced code block header. Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. The state belongs to the block's position in the document: it survives streaming appends and resets when the Markdown is replaced rather than appended to. Code blocks inside blockquotes and admonitions have no toggle and keep scrolling. Needs [`flavor="github"`](#flavor); the toggle's spoken labels come from [`accessibilityLabels.codeBlock`](#accessibilitylabels). iOS and Android only.

<PropInfo type="boolean" default="false" />

### `enableLinkPreview` <IosBadge /> {#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.
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion docs/docs/user-experience/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -117,7 +119,7 @@ Four sharp edges are worth knowing:
- **Android** has no rotor concept, so `accessibilityLabels.rotor.*` is ignored there.
- **Task list items** have no accessibility handling in the React Native package on either platform: a checkbox is read as plain list text, with no checked state announced and no toggle action exposed. There is no label key for it.
- **Admonitions** carry no accessibility role; they are announced as the blockquote they are built from.
- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label, and iOS vends a VoiceOver copy action, but the block itself has no dedicated role.
- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label and the optional wrap toggle (`enableCodeBlockWrapToggle`) speaks `codeBlock.wrap` / `codeBlock.unwrap`; iOS vends both as VoiceOver actions, but the block itself has no dedicated role.
- The platform tabs above describe the renderer. The **editor** speaks neither heading level nor task state (see [Editor accessibility](#editor-accessibility)).

## Reference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ import java.util.EnumSet
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

// Code block wrap state is keyed by block position, so it only stays meaningful
// while the document grows in place. Streaming may rewrite its unfinished last
// line (an HTML tag closing, a link relabeled); anything earlier changing - a
// recycled cell, an edited message, a block inserted above - starts unwrapped.
internal fun keepsCodeBlockWrapState(
previous: String,
next: String,
): Boolean = next.startsWith(previous.substringBeforeLast('\n', ""))

class EnrichedMarkdown(
context: Context,
) : ContainerNodeView(context),
Expand Down Expand Up @@ -244,12 +253,58 @@ class EnrichedMarkdown(
dynamicProps.enableCodeBlockPress = value
}

// Document-order indexes of the top-level code blocks the user wrapped. Kept
// across renders so streaming and segment recreation keep the choice, and
// mirrored into MeasurementStore so the Yoga measure pass sees it.
private var wrappedCodeBlocks: Set<Int> = emptySet()

var enableCodeBlockWrapToggle: Boolean = false
set(value) {
if (field == value) return
field = value
val hadWrappedBlocks = wrappedCodeBlocks.isNotEmpty()
if (!value) setWrappedCodeBlocks(emptySet())
syncCodeBlockWrapState()
if (hadWrappedBlocks && !value) onImageLayoutChanged()
}

private fun setWrappedCodeBlocks(value: Set<Int>) {
wrappedCodeBlocks = value
MeasurementStore.updateWrappedCodeBlocks(id, value)
}

private fun syncCodeBlockWrapState() {
var codeBlockIndex = 0
segmentViews.forEach { view ->
if (view is CodeBlockContainerView) {
view.onWrapToggle = ::codeBlockDidToggleWrap
view.showsWrapButton = enableCodeBlockWrapToggle
view.wrapped = enableCodeBlockWrapToggle && codeBlockIndex in wrappedCodeBlocks
codeBlockIndex++
}
}
}

private fun codeBlockDidToggleWrap(view: CodeBlockContainerView) {
val codeBlockIndex = segmentViews.filterIsInstance<CodeBlockContainerView>().indexOf(view)
if (codeBlockIndex < 0) return
setWrappedCodeBlocks(if (view.wrapped) wrappedCodeBlocks + codeBlockIndex else wrappedCodeBlocks - codeBlockIndex)
onImageLayoutChanged()
}

init {
segmentViewFactory = RootFactory()
}

fun setMarkdownContent(markdown: String) {
fun setMarkdownContent(
markdown: String,
keepWrapState: Boolean = false,
) {
if (currentMarkdown == markdown) return
if (!keepWrapState && wrappedCodeBlocks.isNotEmpty() && !keepsCodeBlockWrapState(currentMarkdown, markdown)) {
setWrappedCodeBlocks(emptySet())
dirtyFlags += DirtyFlag.FORCE_HEIGHT
}
currentMarkdown = markdown
++currentRenderId
renderPending = true
Expand Down Expand Up @@ -466,6 +521,9 @@ class EnrichedMarkdown(
segmentViews.filterIsInstance<TableContainerView>().forEach {
it.accessibilityLabels = labels
}
segmentViews.filterIsInstance<CodeBlockContainerView>().forEach {
it.accessibilityLabels = labels
}
mathContainerClass?.let { mathClass ->
segmentViews.filter { mathClass.isInstance(it) }.forEach { view ->
runCatching {
Expand Down Expand Up @@ -604,6 +662,7 @@ class EnrichedMarkdown(
view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1
}
}
syncCodeBlockWrapState()

if (width > 0) {
val heightBefore = computeSegmentsTotalHeight()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ class EnrichedMarkdownManager :
view.setOnTaskListItemPressCallback { taskIndex, checked, itemText ->
val newChecked = !checked
val updatedMarkdown = TaskListToggleUtils.toggleAtIndex(view.currentMarkdown, taskIndex, newChecked)
view.setMarkdownContent(updatedMarkdown)
// A checkbox flip keeps every code block where it was, so keep their wrap state too.
view.setMarkdownContent(updatedMarkdown, keepWrapState = true)
view.commitProps()
emitTaskListItemPress(view, taskIndex, newChecked, itemText)
}
Expand Down Expand Up @@ -330,6 +331,14 @@ class EnrichedMarkdownManager :
view?.enableCodeBlockPress = enableCodeBlockPress
}

@ReactProp(name = "enableCodeBlockWrapToggle", defaultBoolean = false)
override fun setEnableCodeBlockWrapToggle(
view: EnrichedMarkdown?,
enableCodeBlockWrapToggle: Boolean,
) {
view?.enableCodeBlockWrapToggle = enableCodeBlockWrapToggle
}

@ReactProp(name = "lineBreakStrategyIOS")
override fun setLineBreakStrategyIOS(
view: EnrichedMarkdown?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ object MeasurementStore {

private val streamingCodeBlockModes = ConcurrentHashMap<Int, CodeBlockStreamingMode>()

// Top-level code blocks the user wrapped, by document-order index (see EnrichedMarkdown).
private val wrappedCodeBlocks = ConcurrentHashMap<Int, Set<Int>>()

private fun resolveFontScalingSettings(
viewId: Int?,
props: ReadableMap?,
Expand Down Expand Up @@ -147,6 +150,15 @@ object MeasurementStore {
data.remove(id)
numberOfLinesByViewId.remove(id)
ellipsizeModeByViewId.remove(id)
wrappedCodeBlocks.remove(id)
}

fun updateWrappedCodeBlocks(
viewId: Int,
indices: Set<Int>,
) {
if (indices.isEmpty()) wrappedCodeBlocks.remove(viewId) else wrappedCodeBlocks[viewId] = indices
data.remove(viewId)
}

fun invalidate(id: Int) {
Expand Down Expand Up @@ -491,7 +503,16 @@ object MeasurementStore {
} else {
rawMarkdown
}
val propsHash = computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier)
val wrapped =
if (props.getBooleanOrDefault("enableCodeBlockWrapToggle", false)) {
id?.let { wrappedCodeBlocks[it] } ?: emptySet()
} else {
emptySet()
}
// A Set's hashCode sums its elements, so {} and {0} would collide; the sorted list's doesn't.
val propsHash =
31 * computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier) +
wrapped.sorted().hashCode()

// Streaming shortcut: reuse cached size when the filtered content and
// width are unchanged. When the filter output changes (e.g. a table
Expand Down Expand Up @@ -589,6 +610,7 @@ object MeasurementStore {
val lastIndex = renderedSegments.lastIndex
var totalHeightPx = 0f
var maxContentWidthPx = 0f
var codeBlockIndex = 0

for ((index, segment) in renderedSegments.withIndex()) {
val isLastSegment = index == lastIndex
Expand Down Expand Up @@ -629,7 +651,9 @@ object MeasurementStore {

is RenderedSegment.CodeBlock -> {
totalHeightPx += style.codeBlockStyle.marginTop
totalHeightPx += CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width)
totalHeightPx +=
CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width, codeBlockIndex in wrapped)
codeBlockIndex++
maxContentWidthPx = width
if (includeBottomMargin) {
totalHeightPx += style.codeBlockStyle.marginBottom
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down
Loading