Skip to content

feat: add a per-block line wrap toggle to code block headers - #1010

Closed
juliusmarminge wants to merge 3 commits into
software-mansion:mainfrom
pingdotgg:feat/code-block-wrap-toggle
Closed

juliusmarminge wants to merge 3 commits into
software-mansion:mainfrom
pingdotgg:feat/code-block-wrap-toggle

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What/Why?

Closes #1009.

In the React Native package, a top-level fenced code block scrolls horizontally. On a phone, a long shell command or a one-line JSON blob means swiping back and forth inside a small box. Wrapping every block instead makes indented code harder to read. This lets the reader choose per block.

The new opt-in enableCodeBlockWrapToggle prop (default false) adds a wrap button to the left of copy in each top-level fenced code block header (flavor="github"):

  • Each block starts in the current layout, horizontal scrolling. Tapping wraps that block's lines at the block width, and tapping again goes back to scrolling. Each block toggles on its own.
  • iOS: text.alignleft SF Symbol. Wrapping limits the cached TextKit container to the pane width and turns off horizontal scrolling.
  • Android: Material wrap_text glyph. Wrapping caps the code TextView's width at the pane width inside the existing HorizontalScrollView.
  • Accessibility: VoiceOver gets a custom action next to copy, and TalkBack reads the button. The labels come from the new accessibilityLabels.codeBlock.wrap / .unwrap, with defaults "Wrap lines" / "Disable line wrap".
  • Measurement stays view-free:
    • The root view keeps the wrapped blocks as document-order indexes of top-level code blocks, and the measure pass reads them.
    • On iOS a fingerprint of the set is part of MeasurementCacheKey. On Android it goes into the measure hash.
    • The set resets when anything before the previous document's last line changes, for example a recycled cell, an edited message, or a block inserted above. Streaming appends, rewrites of an unfinished last line, and task checkbox toggles keep it.
  • Not covered: code blocks inside blockquotes and admonitions have no toggle and keep scrolling.

On the API shape, see the alternatives in #1009. The standalone packages' codeBlockLayout defaults to wrapping, so a port there would show the toggle only with scrollable. I'm happy to change this to a third codeBlockLayout-style mode instead of a boolean if you prefer.

We've shipped this in T3 Code's mobile app through our fork: pingdotgg#1, by @Abdullah1738, which this PR carries over. The only difference from the fork is that EnrichedMarkdownText.tsx passes the prop without the fork's media-slot code path.

Testing

  • typecheck, Jest (103 tests) and ESLint on the touched TS files pass. clang-format shows nothing new in the touched ObjC files; the remaining hits are ones upstream main already has.
  • Android: :react-native-enriched-markdown:testDebugUnitTest and ktlintCheck pass. Native unit coverage for the toggle (height parity wrapped and unwrapped, and the reset rule) follows in a separate PR on both platforms.
  • iOS: the ReactNativeEnrichedMarkdown pod builds for the simulator (xcodebuild).
  • Checked in T3 Code's mobile app on an iOS 27 simulator and an Android 17 emulator. The button wraps only its own block, a second tap goes back to scrolling, and the block below is unchanged.

Screenshots

Scrolls (default) Wrapped
iOS iOS: long bash command scrolls off the right edge iOS: the same command wrapped onto four lines
Android Android: long bash command scrolls off the right edge Android: the same command wrapped onto four lines

The screenshots are from T3 Code's app; the list below the code blocks comes from another change (#1005).

Maintainer follow-ups on this branch

  • d25182f0 fixes a pre-existing iOS bug this feature surfaced: toggleTaskListItemAtIndex wrote the inverse checkbox state into the cached markdown since feat: implement task list support #92, so a task-toggle echo from JS never matched the local render.
  • 887f088c keeps the wrap state when that echo arrives, strokes the Android wrap icon with the copy glyph's paint instead of going through the restricted androidx.core.graphics.PathParser, notes in the docs that macOS accepts the prop without a toggle, and trims comments. Verified on iPhone 17 / iOS 26.5 and Pixel 9 / API 36: wrap a block, tap a checkbox with the app echoing the markdown back, the block stays wrapped.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

Adds an opt-in `enableCodeBlockWrapToggle` prop to the React Native
package. Each top-level fenced code block gets a wrap button next to copy;
tapping it wraps that block's lines at the block width, and tapping again
returns to horizontal scrolling.

Wrap state is a set of document-order indexes of top-level code blocks, fed
to the view-free measure pass (part of the iOS measurement cache key and the
Android measure hash). It resets when anything before the previous
document's last line changes, so streaming and task toggles keep it.
Labels come from `accessibilityLabels.codeBlock.wrap` / `.unwrap`.

Closes software-mansion#1009

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hryhoriiK97 and others added 2 commits October 16, 2026 19:33
…kdown

toggleTaskListItemAtIndex wrote `[ ]` for checked and `[x]` for unchecked,
the inverse of Android, since software-mansion#92. The checkbox looked right because the
attributed string is patched separately, but the cached markdown recorded
the opposite state, so anything reading it after a tap saw a stale checkbox
and a prop echo of the toggled document never matched the local render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…icon

iOS reset the wrapped set when the markdown prop changed before its last
line, which a task-toggle echo always does. The reset now skips a prop that
equals the markdown already rendered locally, so the reader's choice stands.

Android drew the wrap glyph through androidx.core.graphics.PathParser, a
RestrictTo API, as a filled shape. WrapGlyph strokes the same Material
outline with the copy glyph's paint, so the two header buttons match.

Docs note that macOS accepts the prop without a toggle. Comments restating
adjacent code are trimmed. CodeBlockContainerViewTest moves to a follow-up
PR that adds native coverage on both platforms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Collaborator

Thanks, this fits the library well: the boolean composes with a future codeBlockLayout prop in a way a tri-state wouldn't, measurement stays view-free on both platforms with the wrapped set in the cache key and the measure hash, and the prop is serialized in conversions.h where #855 was missed. I reviewed it locally on both platforms and pushed two commits onto your branch.

What I found

  • iOS lost the wrap state on a task-toggle echo. Wrap a block, tap a checkbox while the app echoes the toggled markdown back through props, and the block snapped back to scrolling. The reset rule compared the new prop against the old one, and the changed checkbox sits before the last line. Android is immune because the manager updates currentMarkdown with keepWrapState and the echo matches it.
  • The real cause was older than this PR. toggleTaskListItemAtIndex on iOS wrote [ ] for checked and [x] for unchecked, the inverse of Android, since feat: implement task list support #92. The checkbox looked right because the attributed string is patched separately, but the cached markdown recorded the opposite state, so the echo could never match. d25182f0 fixes that on its own.
  • Android drew the wrap glyph through androidx.core.graphics.PathParser, which is @RestrictTo(LIBRARY_GROUP_PREFIX) in androidx.core 1.18, and as a filled shape next to the stroked copy icon.

What 887f088c changes

  • updateProps skips the wrap-state reset when the incoming markdown equals what native already rendered, so the reader's choice survives the echo.
  • WrapGlyph strokes the same Material outline with the copy glyph's paint; PathParser and the SVG string are gone, and the two header buttons match in weight.
  • Docs note that macOS accepts the prop but shows no toggle, matching the #if !TARGET_OS_OSX in the view.
  • Comments that restated adjacent code are trimmed; the ones explaining the reset rule, the set-hash collision and the maxWidth trick stay.
  • CodeBlockContainerViewTest is removed for now. Native coverage for the toggle, the height parity on both platforms and the reset rule, lands in a separate PR so it can use the test support fix: size ordered list markers to their widest number #1015 added.

Verified

  • iPhone 17 / iOS 26.5 and Pixel 9 / API 36, example app: wrap a block, tap a checkbox with the app echoing the toggled markdown back, the block stays wrapped and the checkbox shows the right state. Before the fix the iOS block unwrapped.
  • iOS pod built through the example's unit-test target; Android unit tests and ktlint green after yarn prepare regenerated codegen for the new prop.
  • typecheck, eslint and clang-format clean on every touched file.

One thing for local builds: the new prop needs codegen regenerated (yarn prepare) before the Kotlin override compiles. CI does this itself.

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's hard for me to agree with "Code blocks inside blockquotes and admonitions have no toggle and keep scrolling." Code inside admonitions is common, and a button that only shows up on some blocks looks like a bug to users. I'd like nested blocks supported in this PR.

That would be easier if the wrap logic were pulled out of EnrichedMarkdown into a small per-instance class (e.g. CodeBlockWrapState). Right now it's spread across the field, the setter, sync, toggle and the reset check. The root keeps owning the instance; it just gets passed down to BlockquoteContainerView too. Not a companion object, since that would be shared across all instances. It should also be the only writer: the view emits the toggle, the helper updates the set and MeasurementStore, then pushes wrapped back to the views.

I'm gonna look into fixing this before approving and merging this today.


Heads-up: the API will change from opt-in to opt-out. @juliusmarminge, if you use this in t3code, consider setting enableCodeBlockWrapToggle explicitly.

Comment thread docs-md/API_REFERENCE.md
Comment on lines +244 to +246
| Type | Default Value | Platform |
| --------- | ------------- | ------------ |
| `boolean` | `false` | iOS, Android |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems like a good enchancement for users to have out of the box, so I'd rather have this default to true instead of false

- **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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit

Suggested change
- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label and the optional wrap toggle (`enableCodeBlockWrapToggle`) speaks `codeBlock.wrap` / `codeBlock.unwrap`; iOS vends both as VoiceOver actions, but the block itself has no dedicated role.
- **Code blocks** are covered only partially - under `flavor="github"` the header copy button carries the resolved copy label and the optional wrap toggle (`enableCodeBlockWrapToggle`) speaks `codeBlock.wrap` / `codeBlock.unwrap`, while iOS vends both as VoiceOver actions, but the block itself has no dedicated role.

@eszlamczyk

Copy link
Copy Markdown
Collaborator

Continuing in #1021. I don't have push access to pingdotgg/enriched-markdown ("Allow edits by maintainers" doesn't apply to organization-owned forks), so I pushed this branch upstream with the review fixes in one commit on top. Your commits are unchanged. #1021 will be merged once CI passes. Thanks @juliusmarminge!

@eszlamczyk eszlamczyk closed this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-block line wrap toggle for code blocks in the React Native package

4 participants