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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated change to the pr, fixes the bug that made screens crash on changing the text size on the fly

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|fontScale"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
Expand Down
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 @@ -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.
Expand Down Expand Up @@ -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"
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`

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.

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

### `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`, 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#import "../../ios/utils/ENRMCodeBlockWrapState.h"
#import <XCTest/XCTest.h>

@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<b", "a\n<br>\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
Original file line number Diff line number Diff line change
@@ -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<CodeBlockContainerView>,
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<Int> = 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<Int>) {
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<Int>,
) {
private var nextIndex = 0

fun nextIsWrapped(): Boolean = nextIndex++ in wrapped
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -360,6 +383,9 @@ class EnrichedMarkdown(
segmentViews.filterIsInstance<TableContainerView>().forEach {
it.accessibilityLabels = labels
}
codeBlockViews().forEach {
it.accessibilityLabels = labels
}
mathContainerClass?.let { mathClass ->
segmentViews.filter { mathClass.isInstance(it) }.forEach { view ->
runCatching {
Expand Down Expand Up @@ -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)
Expand All @@ -469,6 +496,7 @@ class EnrichedMarkdown(
view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1
}
}
codeBlockWrapState.sync()

if (width > 0) {
val heightBefore = computeSegmentsTotalHeight()
Expand All @@ -480,6 +508,7 @@ class EnrichedMarkdown(
requestLayout()
}
}
if (remeasure) onImageLayoutChanged()
}

private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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?,
Expand Down
Loading
Loading