Skip to content

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

Merged
eszlamczyk merged 5 commits into
mainfrom
feat/code-block-wrap-toggle
Oct 16, 2026
Merged

eszlamczyk merged 5 commits into
mainfrom
feat/code-block-wrap-toggle

Conversation

@eszlamczyk

@eszlamczyk eszlamczyk commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Takes over #1010 by @juliusmarminge. That PR's branch lives in an organization-owned fork (pingdotgg/enriched-markdown), where "Allow edits by maintainers" grants no push access, so the review fixes couldn't land there. This branch is #1010's three commits unchanged plus two commits on top (dda187bf, 2d2927e0) that apply the review and its inline comments. The discussion so far is on #1010.

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 enableCodeBlockWrapToggle prop (default true; set false to hide it) adds a wrap button to the left of copy in each fenced code block header (flavor="github"), including blocks inside blockquotes and admonitions:

  • 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 wrapped blocks are document-order indexes counted across nesting: a block inside a quote takes the next index, like a top-level one. The view-free measure pass counts the same way, and a wrapped nested block measures at the quote's inner width.
    • 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 prop'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, task checkbox toggles, and the app echoing a toggle back keep it.

The standalone packages' codeBlockLayout defaults to wrapping, so a port there would show the toggle only with scrollable.

The feature was first shipped in T3 Code's mobile app through pingdotgg#1, by @Abdullah1738.

Review changes (dda187bf, 2d2927e0)

  • On by default. enableCodeBlockWrapToggle now defaults to true (codegen spec, JS default, Android @ReactProp and measure-pass fallback, and the native wrap-state helpers); apps that don't want the toggle pass false. Per the inline comment on feat: add a per-block line wrap toggle to code block headers #1010, plus the accessibility doc wording from the other one.

  • One owner for the wrap state. A per-view CodeBlockWrapState (Android) / ENRMCodeBlockWrapState (iOS) holds the wrapped set and is its only writer. The code block view only reports the tap (or the VoiceOver action); it no longer flips wrapped itself, so a missed index lookup can't leave a wrapped view measured as unwrapped.

  • Nested code blocks in blockquotes and admonitions get the toggle on both platforms, with matching measurement and accessibility labels.

  • One reset rule on both platforms: compare the new markdown prop with the previous prop, and keep the state when the prop equals what's rendered. Before, Android compared with the rendered document, so an app that ignored a checkbox tap and kept streaming its own copy unwrapped the blocks.

  • Allocation-free prefix check. The reset check compared via substringBeforeLast / substr, which copied almost the whole previous document on every streamed chunk while a block was wrapped. It now compares in place.

  • Android re-measure after a reset. Yoga measures the new markdown before the prop reaches the view, so it measured with the old wrapped set (replacing the document left space for a wrapped first block). A prop reset now asks Yoga to measure again, as iOS already did.

  • iOS language label is capped before the header buttons and truncated with an ellipsis, like Android, instead of running under the wrap button.

  • Example app (Android): MainActivity now handles fontScale itself; before, a system font change recreated the activity and crashed in react-native-screens' fragment restore.

  • Unit tests for the reset rule and the nested counting order (Android CodeBlockWrapStateTest, iOS ENRMCodeBlockWrapStateTests).

Also in this PR

d25182f0 fixes a pre-existing iOS bug this feature surfaced: toggleTaskListItemAtIndex (ios/utils/TaskListTapUtils.m) wrote the inverse checkbox state into the cached markdown since #92. The helper is shared, so EnrichedMarkdownText changes too. Release notes line:

fix(ios): tapping a task checkbox wrote the opposite checked state into the markdown (affects EnrichedMarkdownText too)

Testing

  • Jest (103 tests) passes.
  • Android: :react-native-enriched-markdown:testDebugUnitTest and ktlintCheck pass.
  • iOS: the example's unit tests (yarn test:ios) pass; clang-format is clean on the touched files.
  • iOS: checked by hand in the example app: wrapping top-level and nested blocks, streaming, task toggles, replacing the document, turning the prop off, font scale.
  • Android: the same manual pass is in progress; the replace-document re-measure fix and the fontScale change came out of it.

Screenshots (from #1010)

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

Conversation on #1010

Copied here so it's readable without opening #1010. Comments are quoted verbatim, oldest first.

@hryhoriiK97 · comment · Oct 16 (link)

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.

@hryhoriiK97 · approved · Oct 16

(no body)

@eszlamczyk · inline on docs-md/API_REFERENCE.md · Oct 16 (link)

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

@eszlamczyk · inline on docs/docs/user-experience/accessibility.md · Oct 16 (link)

Nit

- **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 · changes requested · Oct 16 (link)

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.

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)

Abdullah1738 and others added 3 commits October 15, 2026 17:31
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 #1009

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kdown

toggleTaskListItemAtIndex wrote `[ ]` for checked and `[x]` for unchecked,
the inverse of Android, since #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>
@eszlamczyk
eszlamczyk force-pushed the feat/code-block-wrap-toggle branch from 0caad5c to dda187b Compare October 16, 2026 20:44
@eszlamczyk

eszlamczyk commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Verified everything via tests and manual checks, on both platforms:

  • Toggle a top-level block: lines wrap, height grows, nothing clipped below
  • Toggle a block in a blockquote and in an admonition: same, measured at the quote's inner width
  • Wrap block 2 only, then stream more text: block 2 stays wrapped, block 1 doesn't
  • Tick the checkbox above wrapped blocks: they stay wrapped
  • Replace the markdown with a different document: all blocks unwrap
  • Turn the prop off while blocks are wrapped: buttons hide, blocks unwrap, height shrinks
  • Change font scale / dark mode while wrapped: state kept, height right
  • Long language name (e.g. ```objective-cpp-header): no overlap with the wrap button on iOS
  • Rotate the device while wrapped: re-wraps at the new width
  • Screen reader: VoiceOver custom action and TalkBack button read "Wrap lines" / "Disable line wrap"|

Via a throwaway screen

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

@eszlamczyk
eszlamczyk merged commit 2547e70 into main Oct 16, 2026
20 checks passed
@eszlamczyk
eszlamczyk deleted the feat/code-block-wrap-toggle branch October 16, 2026 21:27
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

3 participants