Skip to content

feat: add opt-in native link pill presentation - #862

Merged
hryhoriiK97 merged 17 commits into
software-mansion:feat/link-pillsfrom
juliusmarminge:link-pill/presentation
Oct 4, 2026
Merged

hryhoriiK97 merged 17 commits into
software-mansion:feat/link-pillsfrom
juliusmarminge:link-pill/presentation

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

What/Why?

Opt-in pill presentation for Markdown links on iOS and Android. A link whose markdownStyle.linkVariants entry sets pill is drawn as a rounded label with an optional icon; every other link is unchanged. Refs #860.

The PR was started by @juliusmarminge. The implementation was then reworked on top of his commits; the comment below explains what changed and why. This description covers the current head.

  • Style: pill: true uses the defaults; pill: { label, iconUri, borderRadius, paddingHorizontal, paddingVertical, borderWidth, borderColor, maxWidth } customizes it; omitted, false or null keeps an ordinary link. Colors, underline and font family stay on the link variant.
  • Per-link content: the new linkPillContent prop sets the label and icon of individual links by exact URL. It is content, not style, so markdownStyle can stay a stable object while it changes. Per-link content wins over the variant's pill.label / pill.iconUri, which win over the link text.
  • Icons: local files and bundled assets show on the first layout. http(s) sources load through the same pipeline as Markdown images, using imageRequestHeaders, and the pill keeps room for them while they load. A source that fails shows no icon and is retried after 30 seconds.
  • Layout: a pill wraps as one unit and truncates at the available width or maxWidth. It works in paragraphs, headings, lists, blockquotes and table cells.
  • Links that stay ordinary: a link whose text contains an image, inline math, a hard line break or a spoiler. A pill inside an unrevealed spoiler is hidden with the rest of the spoiler.
  • Text leaving the view: copy, Markdown/HTML/RTF export, accessibility labels and the iOS selection actions (Look Up, Translate, Share) receive the link's own text and URL.
  • Web and macOS: render ordinary links and ignore pill and linkPillContent.

API

<EnrichedMarkdownText
  markdown="Open [src/components/a/long/path/file.ts](https://example.com/files/a/long/path/file.ts)"
  markdownStyle={{
    linkVariants: {
      '^https://example\\.com/files/': {
        color: '#3730A3',
        backgroundColor: '#EEF2FF',
        pill: { iconUri: 'file_icon', borderRadius: 10, maxWidth: 180 },
      },
    },
  }}
  linkPillContent={{
    'https://example.com/files/a/long/path/file.ts': { label: 'file.ts' },
  }}
/>

Docs: docs/MENTIONS.md (pill section), docs/API_REFERENCE.md (linkPillContent), docs/WEB.md, docs/MACOS.md.

Implementation

  • iOS: a pill link is one attachment character (ENRMLinkPillAttachment), laid out and drawn by TextKit like images and inline math. The link's rendered text is kept on the attachment and put back by ENRMLinkPillText wherever text leaves the view. ENRMMarkdownTextView does the same for the system selection actions. This replaces the custom text storage and glyph substitution of the first version.
  • Android: LinkPillSpan is a ReplacementSpan over the original link text, which stays in the buffer. Widths are prepared per layout thread through a shared helper (prepareWidthAwareSpans), and a span change redraws the pill when its icon arrives or a spoiler over it is revealed.
  • Shared: pill is a nested object on the native link variant; linkPillContent is passed like imageRequestHeaders. Measurement caches on both platforms account for link variants and pill content.
  • Outside the feature: the Android ImageDownloader now reports a request OkHttp rejects as a failed download instead of throwing into the render. The example's iOS test target inherits only the pods' search paths, because linking the pods into the test bundle loaded every library class twice.
  • Packaging: android/src/test is excluded from the npm package (files in package.json). The android folder is published whole, so the unit tests shipped with it; a dry run of npm pack now lists no test files.

Testing

CI passed on 5a694ac1: RN lint, library build, Android and iOS builds, Android and iOS unit tests. The one commit after it (60896773) only adds the npm files exclusion.

  • Jest: link-pill-style.test.ts (style normalization, content normalization and equality).
  • Android: 38 Robolectric tests in LinkPillSpanTest.kt.
  • iOS: 19 XCTest cases in ENRMLinkPillAttachmentTests.mm, now registered in the example's test target and run by CI.
  • Manual: iOS simulator (iPhone 17, iOS 26.5) and an Android emulator, with a local demo screen that is not part of this PR. Checked rendering in every block type, remote icons arriving late, a failing icon, and spoiler reveal.

Not covered: drag-and-drop of a selection containing a pill on iOS, and Maestro E2E. After main was merged into the branch, the on-device check was repeated only for rendering on Android.

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)
Original description (written for head 7781ab1, before the rework)

What/Why?

Extend the existing markdownStyle.linkVariants configuration with optional iOS/Android pill presentation for explicit Markdown links. Consumers supply label, local icon URI and geometry through pill: { ... }; pill: true uses defaults, and omission, false or null disables it. Link colors and font family remain variant-level fields. Pills wrap as one unit and truncate within the native content width. pill defaults to false; ordinary links keep their existing presentation when it is omitted.

The original characters and URL stay in native text storage for link callbacks, accessible names including both visible and original link text, copy and Markdown export. Pills use the existing block fonts, including their weight, and reach table cells and existing measurement stacks. Inline-code backgrounds are suppressed beneath the pill while code semantics stay intact. Native icon caches are bounded and downsampled.

TextKit retains attachment attributes during ordinary fixing, anchors bounds/images to the first original source character, and tracks callback continuation per layout manager without surrounding glyph queries. Ordinary and unrelated-attachment glyph callbacks skip full buffer copies and longest-range scans.

Refs #860. The font-family foundation #861 merged on September 24, 2026. The maintainer merged upstream main into this branch; that merge is preserved exactly. Review the core diff against current upstream main. Optional icon tint follows in #863.

API

linkVariants: {
  '^https://example\\.com/': {
    color: '#1264A3',
    backgroundColor: '#E8F5FB',
    fontFamily: 'CustomFont',
    pill: { label: 'Document', iconUri: 'file:///path/icon.png', maxWidth: 180 },
  },
}

The internal flat native transport stays aligned with the existing library normalization patterns. iOS icons use the shared file-backed local/bundle resolver and ImageIO thumbnails with a pressure-aware NSCache. Android icons reuse LocalImageLoader and ImageCache with a 512-pixel bound on both axes and bounded cache identity strings. Ordinary image-loading defaults remain unchanged. usesFontLeading = NO now matches the other TextKit stacks. Storage fixing has a no-pill fast path and documents its wider preservation scope.

Testing

Earlier reviewed dedicated-worktree source passed TypeScript, 8 Jest suites/53 tests, Bob modules/declarations and both native code generators. Android ktlint, 17 native-graphics Robolectric regressions and library debug AAR compilation passed. Scoped formatting/whitespace checks and normal commit hooks passed. Regressions cover nested opt-in/default/disabled configuration, accessible link nodes retaining visible and original text, local icon downsampling, cache eviction without recycling visible owners, and original Markdown/callback semantics.

iOS XCTest source includes attachment/source geometry, fragmented attributes, streaming UITextView, pill removal/reintroduction, accessible names and no-font-leading measurement regressions. It remains unwired/unrun. The RN harness is now wired; the maintainer owns the remaining pill test-target registration. Previous-head upstream CI passed RN lint, library, iOS and Android builds on September 23, 2026. These are compilation checks; they do not execute this unwired RN XCTest suite or prove device rendering. No isolated-head device execution was performed.

September 24 update: current core is 7781ab17; tint is 0eca6691. Core integrates upstream main cdb54825 and preserves all preceding maintainer commits exactly. The new iOS paragraph-style conflict adopts upstream's minimum line-height behavior for tall inline code while retaining pill box height in baseline centering. Upstream list-item and code-block adjustments and documentation are preserved. Added XCTest source compares original-character pill geometry and exported Markdown with a replacement-character reference at line-height floors below and above the pill. This test remains unwired/unrun. No public API, Android or JavaScript source delta in this update. Both tint patches replay identically.

On these exact heads, TypeScript and all nine Jest suites passed: core 58 tests, tint 60 tests. Scoped clang-format, whitespace checks and normal commit hooks passed. The preceding 5260f94f/ec5ab1fd heads passed 18/20 focused Android native graphics tests, Kotlin lint, Bob/module/declarations/native codegen locally; their upstream Android build/test jobs passed, with iOS jobs still queued at refresh. Those results precede this iOS compatibility update. Both current branches are free of the reported main conflict. Current-head CI completed successfully, verified through fresh REST run/check/job reads on September 24, 2026 at 18:02 UTC. RN lint, library, Android build/unit tests and iOS build/unit-test jobs passed, with the aggregate ci-success check passing. The successful iOS test job does not execute the unregistered pill XCTest source. This CI result is separate from the combined-consumer UI proof below and does not establish current-head approval or merge readiness. The RN harness still excludes ENRMLinkPillTextStorageTests.mm from its explicit compile-source list. Pill XCTest and strict reference geometry remain unwired/unrun. Isolated upstream-head UI remains unrun. The consumer owner completed integrated native validation for these exact sources on September 24, as labeled below.

Combined-consumer proof and limits

On September 24, 2026, the consumer owner rebuilt, installed and personally inspected the integrated iOS app containing core 7781ab17 and tint 0eca6691. This is combined-consumer validation containing other library and consumer changes. It is not isolated upstream-head UI proof or a controlled upstream base/head comparison. Installed native fingerprint: c6809326ed4555205bcbb19db0319156b120257c.

Temporary style probes used 34px inline code with 18px and 44px paragraph/list/quote line-height floors, below and above the pill height. The owner visually inspected full code glyphs, one pill per original source link and no observed overlap. These are visual observations; strict replacement-character reference geometry assertions were not run. The shipping wrapper was then restored byte-identical and fully reloaded.

18px floor, temporary probe44px floor, temporary probe
Combined-consumer temporary probe with 34px inline code and 18px paragraph, list and quote line-height floorsCombined-consumer temporary probe with 34px inline code and 44px paragraph, list and quote line-height floors

Additional 44px list/quote probe capture.

The restored shipping app showed one pill per root, inline-code, quote, nested-quote, table and quoted-table link. Actual native accessibility nodes included environmentPresence.ts plus its original path and Mobile Check plus $mobile-check. After genuine provider metadata resolved following the full reload, the shipping skill menu copied $mobile-check. Relative/full file clipboard values were exact, and routing opened the correct six-line source viewer. Native menus remained anchored in root, quote, nested quote and quoted table contexts. Nonlink quoted-table Copy as Markdown retained the original source label and URL.

Restored shipping combined-consumer iOS app showing one pill per original link after the line-height compatibility update

Restored shipping quoted-table menu capture. Temporary style probes are separate from this restored shipping evidence.

Combined build provenance and checks

Combined library source is local commit 105fd60e2c57dfec0370ccef577c253ac9d3ec7f; consumer source is 5b6a1441. The consumer's existing #12781 records the inspected evidence. The local combined commit has no claimed GitHub URL. Contributor coauthor credit was retained.

The prepared archive was react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz, 1,033,101 bytes and 926 files, SHA-256 59ba5df5492476181ecbe1ba573a1c163c4da3eaeea0431f30ed5ea235876319. The combined library/consumer passed 14 JavaScript suites/93 tests, types, Bob and both native code generators, normal hooks and frozen installation on Linux and Mac. These are combined-source checks, not a new claim about isolated upstream tests.

The official iOS retry rebuilt and installed successfully. Its first attempt exhausted disk and supplied no proof. The successful retry used the previously documented host-only XCODE_XCCONFIG_FILE resource overrides: CLANG_ENABLE_EXPLICIT_MODULES=NO, SWIFT_ENABLE_EXPLICIT_MODULES=NO, GCC_GENERATE_DEBUGGING_SYMBOLS=NO, DEBUG_INFORMATION_FORMAT=dwarf. These are consumer-host settings, not upstream CI or shipped native-harness configuration.

The full Android consumer APK retry passed, 1,301 tasks in 7m45s. Its first attempt exhausted tmpfs before native compilation and supplied no proof. Shipping Gradle configuration was retained. Android UI was not run; the Device host AVD-listing failure persists.

Native pill XCTest target registration remains maintainer-owned, and that suite and strict U+FFFC reference geometry remain unwired/unrun. No isolated upstream-head UI, Android UI or VoiceOver traversal claim. Consumer full main CI was not observed; fresh metadata/config checks alone do not prove CI. The library example and Maestro E2E were not run. No new full video-playback claim. Earlier September 23 evidence remains recorded in the consumer PR and prior handoff; the September 24 captures above are the current combined-consumer proof for these exact core/tint sources. Maintainer approval is against an earlier reviewed core and does not prove current-head CI or native tests; the maintainer plans to defer merging until next week to exclude the upcoming release. Consumer recognition, metadata and actions remain outside the native API.

PR Checklist

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

🤖 Generated with Claude Code

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

Hi @juliusmarminge thanks for this big work! Strong PR overall, the pill design is sound, it correctly preserves the text under presentation layer, and the Android test suite is genuinely thorough. In the spirit its already approved, but there are at least four main areas I need attention before we can think about merging this. Rest are nitpicks, that I still'd like to have them resolved, they arent blocking ones.

  1. API / native TextKit usage. ENRMPillLayout omits usesFontLeading = NO maink it the only TextKit stack that violates the "must be set to NO rule (ENRMViewFreeMeasurement.h`)

  2. Image loading/caching. The pill icon cache reinvents already existing caching for both IOS and android. Additionaly the file-only buset bunde-asset handling. I'd like already working solutions reused.

  3. Accessibility. Currently with label override enabled the pill shows something different than the Talkback/voiceover say. This is a violation of WCAG 2.5.3 (Label in Name). We should resolve this before merging.

  4. API shape, currently the API is very unpleasant for end user to work with, more informations are in the relevant inline comments

Comment thread packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts Outdated
Comment thread packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillTextStorage.m Outdated
Comment thread packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillTextStorage.m Outdated
Comment thread packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m Outdated
Comment thread packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts Outdated

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.

Excellent, real-graphics test coverage (pixel + exact-width assertions, not shadow no-ops). Note these aren't in CI yet - the lane only runs the standalone @enriched-markdown/android package. Harness is ready; I'll wire the RN Android + iOS test lanes in a separate follow-up PR.

Thanks for the good tests!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The RN Android suite passed locally through the library harness: 17 core tests and 19 after tint propagation in e3e7b18e. This is not upstream CI execution. I have left RN native test-lane wiring to your follow-up, and the iOS XCTest suite remains explicitly unwired/unrun.

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

Hi again @juliusmarminge, everything looks good to me! All the issues got addressed and I believe everything should be alright.

One note, we will not merge this before next week, because we don't want this to land into next release. Due to this I will review rest of the pull requests in this stack (they appear as smaller changes that should have less pushback) after merging of this one

eszlamczyk and others added 3 commits September 24, 2026 15:25
Keep the shared inline background geometry and concealed-spoiler guard while retaining pill coverage suppression and source spans. Add a native graphics regression for partial pill coverage during spoiler concealment and reveal.
Keep upstream paragraph line heights as minimums while retaining pill box height in baseline centering. Add source-preserving attachment reference coverage for line heights below and above the pill. Native XCTest execution remains maintainer-owned.

@hryhoriiK97 hryhoriiK97 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.

@juliusmarminge a few comments from my side. I've tested this locally on both platforms (unit tests plus a demo screen in the example app) and it looks fine overall — the pills render well and I saw no slowdown for documents without them.

I left some inline comments. Four I'd like fixed before merge: the Android blockquote offset, the style cache miss for nested pill objects, the iOS test file not compiling, and the accessibility lookup that regresses #898 after merging main. The rest are smaller.

Comment thread packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts
Comment thread packages/react-native-enriched-markdown/ios/segments/ENRMTableIOSGridView.m Outdated
Comment thread packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m Outdated
Comment thread packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts
Comment thread docs/MENTIONS.md
hryhoriiK97 and others added 6 commits October 4, 2026 10:39
…link content

Rework the link pill presentation on top of the original implementation.

iOS: a pill link is now a single attachment character laid out and drawn by
TextKit, like images and inline math, instead of hidden link characters
substituted through a custom text storage. The link's own text is kept on the
attachment and put back wherever text leaves the view: copy, Markdown/HTML/RTF
export, accessibility, and the system selection actions.

Android: pills prepare their width per layout thread through a shared helper,
are set directly while rendering, and are redrawn through a span change when an
icon arrives or a spoiler over them is revealed.

Both platforms:
- `linkPillContent` prop: per-link label and icon keyed by exact URL
- `pill` is a nested object on the native link variant
- remote icons through the existing image pipeline, with bounded caches and a
  retry window for failed sources
- links holding an image, inline math, a hard line break or a spoiler stay
  ordinary links; a pill inside an unrevealed spoiler is hidden
- measurement caches account for link variants and pill content

Also makes the shared Android image downloader report a request OkHttp rejects
as a failed download instead of throwing into the render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add the pill attachment tests and the library header paths they import to
EnrichedMarkdownExampleTests, and let the target inherit only the pods' search
paths: linking the pods into the test bundle as well loaded every library class
twice.

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

hryhoriiK97 commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

@juliusmarminge @eszlamczyk a heads-up on where this PR is going.

Instead of continuing in review comments, I pushed commits on top of Julius's branch. They rework how pills are implemented. The public pill option is unchanged, so existing linkVariants configs keep working. I also updated the PR description to match the current head; the original text is kept at the bottom of it.

Why the change of direction

On iOS the first version kept the link's characters in text storage and hid them with a custom text storage and glyph substitution. It worked, but it was a second layout mechanism next to the one we already use for images and inline math, and most of the points in my review came from it. I want pills to go through the same path as the other inline attachments, so they share the layout, measurement and table code we already maintain.

What changed

  • iOS: a pill is now a single attachment character, laid out and drawn by TextKit. The link's own text is kept on the attachment and put back wherever text leaves the view: copy, Markdown/HTML/RTF export, accessibility and the system selection actions (Look Up, Translate, Share). That is the trade-off of this design: new code that takes text out of the view has to call the same helper.
  • Android: the approach is the same as before, a replacement span over the original link text. The changes are per-thread layout state, a redraw when an icon arrives or a spoiler is revealed, and performance fixes.
  • New prop linkPillContent: label and icon per link, keyed by exact URL, so markdownStyle can stay a stable object while the content changes.
  • Remote icons: iconUri also accepts http(s) URLs, loaded through the image pipeline with imageRequestHeaders.
  • Spoilers: a pill inside an unrevealed spoiler is hidden, and a link that contains a spoiler stays an ordinary link.
  • Tests: the iOS pill tests are now part of the example's test target and run in CI, next to the Android and Jest ones.
  • The points from my earlier review are addressed in these commits.

main is merged in and CI is green on the current head.

What I'd ask from you

  • @eszlamczyk: your approval was for the previous implementation, so please take another look at the current head.

Known gaps: drag-and-drop of a selection that contains a pill is untested on iOS, and there are no E2E tests for pills yet.

The android folder is published whole, so android/src/test shipped with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 changed the base branch from main to feat/link-pills October 4, 2026 10:56
@hryhoriiK97
hryhoriiK97 merged commit 83ba5ba into software-mansion:feat/link-pills Oct 4, 2026
15 checks passed
@hryhoriiK97 hryhoriiK97 mentioned this pull request Oct 4, 2026
4 of 6 tasks
@hryhoriiK97

Copy link
Copy Markdown
Collaborator

Update on how this lands.

I merged this PR into feat/link-pills, a new integration branch in this repo, not into main. The branch holds exactly this PR's last head: Julius's implementation plus the rework from my comment above.

From here:

hryhoriiK97 added a commit that referenced this pull request Oct 7, 2026
* feat: add opt-in native link pill presentation (#862)

* feat: allow link variants to override font family

* fix: cleanup; docs: storybook

* feat: add opt-in native link variant pills

* fix: render iOS pill attachment only at its first source character

* fix: track pill glyph continuations without recursive layout queries

* perf: skip iOS pill glyph copies and range scans for ordinary text

* fix: address native link pill review findings

* refactor(link-pill): render pills as a native attachment and add per-link content

Rework the link pill presentation on top of the original implementation.

iOS: a pill link is now a single attachment character laid out and drawn by
TextKit, like images and inline math, instead of hidden link characters
substituted through a custom text storage. The link's own text is kept on the
attachment and put back wherever text leaves the view: copy, Markdown/HTML/RTF
export, accessibility, and the system selection actions.

Android: pills prepare their width per layout thread through a shared helper,
are set directly while rendering, and are redrawn through a span change when an
icon arrives or a spoiler over them is revealed.

Both platforms:
- `linkPillContent` prop: per-link label and icon keyed by exact URL
- `pill` is a nested object on the native link variant
- remote icons through the existing image pipeline, with bounded caches and a
  retry window for failed sources
- links holding an image, inline math, a hard line break or a spoiler stay
  ordinary links; a pill inside an unrevealed spoiler is hidden
- measurement caches account for link variants and pill content

Also makes the shared Android image downloader report a request OkHttp rejects
as a failed download instead of throwing into the render.

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

* docs(storybook): add link pill story

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

* test(ios): run the link pill tests in the example test target

Add the pill attachment tests and the library header paths they import to
EnrichedMarkdownExampleTests, and let the target inherit only the pods' search
paths: linking the pods into the test bundle as well loaded every library class
twice.

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

* docs: document link pill content and behavior

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

* chore(example): match the Podfile checksum to the test target change

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

* chore: keep Android unit tests out of the npm package

The android folder is published whole, so android/src/test shipped with it.

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

---------

Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Gregory Moskaliuk <mosckalyuck@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): keep list markers on the baseline when an item starts with an attachment

TextKit places an attachment glyph at the bottom edge of its bounds, not on
the baseline. The marker drawer read the first glyph's location as the
baseline, so an item that starts with a link pill, an inline image or inline
math drew its bullet, number or checkbox too low by however far the
attachment hangs below the text.

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

* feat(link-pill): add pill.lineHeight to keep stacked pills apart

A pill is as tall as its font's line plus its padding and border, and a
line grows to fit it, but only to the pill's own height. Pills on
consecutive lines therefore touch whenever the line height leaves no
room, which it rarely does with the default padding.

`pill.lineHeight` is a floor for the block that holds the pill: a
paragraph, list item, heading or quote gets lines at least that tall, on
every line, so its spacing stays even. It only raises the block's own
`lineHeight`, scales with the font like the block line heights do, and
is unset by default, which leaves every existing layout as it was.

Each platform folds it into the step where a block already sets its line
height. On Android the floor takes the flags of the block's own line
height span, so that it applies before the spans that add a margin to a
line.

For text that streams in, the docs recommend the block's own
`lineHeight` instead: a line height that depends on whether a pill is
there changes the moment a link becomes one.

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

* test(link-pill): remove the native unit tests

Drops LinkPillSpanTest.kt (Android) and ENRMLinkPillAttachmentTests.mm
(iOS), and with them the wiring that existed only for those tests: the
example's test target goes back to how main sets it up (Podfile,
Podfile.lock, Xcode project, the iOS tests README), and the Android
library no longer includes resources in unit tests.

The Jest tests for pill style and content stay. So do the harness smoke
tests from main, which keep both native test jobs running.

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

* fix(link-pill): drop null from the pill variant type

A variant either sets a pill or leaves it out; null was a third way to
say the same thing as false.

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

* fix(android): release a pill's icon slot when its download fails

A remote icon that failed to load left its slot reserved for as long as the
document stayed static, so the pill showed a blank gap in front of its label.
The cache now reports the failure and the span gives the slot up, reflows its
hosts and has the component (or a table cell) re-measure, as iOS already did.

Also resolve pill content through one fallback helper: LinkPillStyle holds a
LinkPillContent, and the span and both ReadableMap parse sites go through its
orElse / fromReadableMap instead of spelling the chain out twice.

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

---------

Co-authored-by: Julius Marminge <jmarminge@gmail.com>
Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
eszlamczyk added a commit that referenced this pull request Oct 9, 2026
…#890)

* fix(test): give the RN iOS test target the library header search paths

The EnrichedMarkdownExampleTests target introduced in #877 only searched
core/cpp/*, ios/internals and ios/input/internals. Any real suite importing
library headers by relative path fails to build, because those headers import
their siblings unqualified: ENRMLinkPillAttachment.h imports "ENRMUIKit.h",
which lives in ios/utils and was unreachable.

The placeholder smoke test imports nothing from the library, so the lane was
green without ever proving a header could be resolved.

vendor/ and generated/ stay out: sweeping vendor in pulls three RaTeX
xcframework module maps into scope and clang rejects the duplicate RaTeXFFI
module definitions.

Created with usage of AI tools

* test(ios): register the link pill XCTest suite in the test target

EnrichedMarkdownExample.xcodeproj is objectVersion 54 and lists sources
explicitly, so nothing under __tests__/ios is discovered automatically. #877
said the real suites "plug into the same target on merge"; that holds for
Android, where Gradle auto-discovers src/test/java, but on iOS each file needs
a PBXFileReference, a PBXBuildFile, a group entry and a Sources phase entry.

Adds ENRMLinkPillTextStorageTests.mm from #862. With this plus the header
search paths, the lane runs 13 tests instead of the placeholder alone.

This branch is red until #862 merges: the .mm it references does not exist on
main yet. It also needs the one-line NULL -> nullptr fix on #862 itself
(ENRMLinkPillTextStorageTests.mm:254) -- XCTAssertNotEqual(bitmap, NULL)
compares CGContextRef against __null, which is a hard error in ObjC++.

Created with usage of AI tools

* test(ios): auto-discover the library XCTest sources

Replaces the per-file pbxproj registration added in the previous commit.
EnrichedMarkdownExample.xcodeproj is objectVersion 54 and lists sources
explicitly, so every new __tests__/ios file needed four pbxproj entries,
and a file nobody wired stayed silently unrun - which is how the lane
passed while covering nothing.

A "Generate library test sources" phase on the test target now globs
__tests__/ios and emits an include list that one wired umbrella source,
ENRMLibraryTests.mm, pulls in. Adding a test file there needs no project
change, matching what Gradle already does for src/test/java on Android.

test-ios.sh additionally counts the "- (void)test" prototypes present and
fails when fewer tests than that ran, so a suite dropping out of the run
cannot leave the lane green.
eszlamczyk added a commit that referenced this pull request Oct 9, 2026
* refactor(ios): split the style structs into one file each (#878)

MarkdownStyleConfig.swift held every resolved style record and sat at
561 lines against the 600-line swiftlint ceiling, with ImageStyle.swift
as the lone exception. Move the other ten (ElementStyle,
BaselineShiftStyle, ThematicBreakStyle, CodeBlockStyle, AdmonitionStyle,
BlockquoteStyle, ListStyle, TaskListStyle, TableStyle + TableAlignment,
SpoilerStyle) into Theme/Styles/, one per file, and ImageStyle.swift in
with them. Pure move: no declaration changes.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ios): align the public API with SwiftUI conventions (#879)

* refactor(ios): align the public API with SwiftUI conventions

Applies the SwiftUI API audit of the package. Every 0.1 name stays as a
deprecated shim under Sources/EnrichedMarkdown/Deprecated/ (renamed: for
pure renames, so Xcode offers fix-its); removal waits for the next major.

- `Md4cFlags` -> `MarkdownParsingOptions`, `flags:` -> `options:` on
  EnrichedMarkdownText, MarkdownRenderer.render/renderLaTeX, and Parser
- link taps go through the `openURL` environment action; a legacy
  `onLinkPress` handler still wins when installed, and `onLinkLongPress`
  becomes `onMarkdownLinkLongPress`. openURL alone leaves a long-press to
  the system menu instead of firing the tap
- `.font(_:)` resolves every text-style spelling, including design and
  weight, from a hashed table; any other Font logs a runtime warning
  before the `.body` fallback. fontSize/fontFamily stay as the explicit
  forms for point sizes and custom faces
- `cornerRadius` (BlockImage, CodeBlock, Table), `checkboxCornerRadius`,
  `marginLeading`, `multilineTextAlignment`, `foregroundStyle` on
  ThematicBreak and Spoiler, `Table.alignment(_: HorizontalAlignment)`,
  `onTaskListItemToggle` with `TaskListItemToggle(index:isChecked:text:)`,
  `MarkdownSelectionMenu(copyImageURL:)`, `markdownTextSelection(.enabled)`
- the style records rename the same fields; each deprecated memberwise
  init requires its old label so calls without it pick the live init
- CodeBlock conforms to MarkdownThemeElement (gains fontDesign, bold,
  alignment; the renderer honors it); Blockquote, List, and Table apply
  text styles through one package TextStyleRecord protocol
- rememberMarkdownTheme is deprecated: it never did anything SwiftUI's
  body re-evaluation does not

README examples use the new names and end with a migration table; the
example app is migrated.

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

* refactor(ios): group modifiers whose values belong together

Grouped where SwiftUI bundles the same tuple in one modifier, or where
the values only apply under one overlay; independent values stay flat.
Every 0.1 form stays as a deprecated shim under Deprecated/.

- `.border(_:width:)` on CodeBlock, Blockquote, and Table through a new
  BorderThemeElement protocol, as SwiftUI's border(_:width:). A nil width
  keeps the width a lower theme layer set, so a dark-mode layer can
  recolor a border without restating it. TaskList keeps borderColor: it
  has no width to pair with
- `Table().cellPadding(horizontal:vertical:)`, either side optional
- spoiler tuning moves from the theme onto the overlay choice:
  `.markdownSpoilerOverlay(.particles(density:speed:))` and
  `.markdownSpoilerOverlay(.solid(cornerRadius:))`, as static functions
  beside the bare `.particles` / `.solid`. The providers keep their
  parameters in stored properties, so Equatable synthesis rebuilds the
  overlays when they change. The views take the provider's value, then
  the theme's (written only by the deprecated modifiers), then the
  built-in default. `Spoiler()` keeps foregroundStyle and background
- the stored field and SpoilerStyle field become solidCornerRadius

Kept flat on purpose: checkbox size and corner radius, superscript and
subscript scales, list bullet and marker values.

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

* refactor(ios): close the remaining gaps to SwiftUI conventions

A second pass over the public surface after the audit renames.

- `.foregroundStyle(.secondary)` and friends now compile: every Color
  overload is @_disfavoredOverload, so a bare member name resolves to
  the semantic color while an explicit Color still takes the Color
  path. The default theme's private typealias workaround is gone
- `MarkdownStyleConfig` -> `MarkdownStyleConfiguration` (deprecated
  typealias); the `config:` label and local names stay short
- `fontWeight(_:)` and `italic()` join `bold()`, and all four, plus
  `fontDesign`, layer over the font a lower theme set. `Heading(1).bold()`
  on its own was a silent no-op before, and switching to the
  monospaced design reset a semibold face to regular
- `font(size:weight:design:)` and `font(custom:size:)` replace
  `fontSize(_:weight:)` and `fontFamily(_:size:)`, shaped like their
  `Font` counterparts
- Table: `headerFont(_:)` takes a text style, `headerFont(custom:size:)`
  replaces `headerFontFamily`, `headerForegroundStyle` replaces
  `headerTextColor`
- `.tertiary` joins the semantic colors; `MathBlock().font(size:)`
  replaces `fontSize`

`isItalic` is a new MarkdownThemeElement requirement with a
storage-less default, so outside conformers keep compiling. Every 0.1
name stays as a deprecated shim.

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

* fix(ios): clear the package's compiler warnings

A clean build of the example app surfaced five warnings from the
package, all older than this branch:

- BlockquoteRenderer computed a `depth` it never read
- EmphasisRenderer and StrongRenderer passed an optional font where
  `Any` is expected, boxing the optional; both now unwrap it first
- MarkdownTheme and MarkdownThemeGroup are Sendable but held
  `any MarkdownThemeContent`, an error in Swift 6. The protocol now
  requires Sendable; every element is a struct of Sendable values

The three md4c.c warnings are in the shared core and are left alone.

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

* refactor(ios): remove dead code

A reference sweep over the package found nothing private that is dead
and no theme field that no renderer reads. Six declarations had no
callers outside their own tests, or none at all:

- the empty `EnrichedMarkdown` namespace enum. Nothing used it, and a
  type named after its module takes over qualified lookup, so
  `EnrichedMarkdown.List()` failed with "type has no member". With it
  gone, module qualification disambiguates our List, Link, and Table
  from SwiftUI's as documented
- `ThemeResolver.font(from:traitCollection:)`, a wrapper with no callers
- `RenderContext.reset()`, no callers
- `RenderContext.spacerStyle(height:spacing:)` and its template, a
  test-only duplicate of ParagraphStyleHelpers.spacerParagraphStyle
- `ParagraphStyleHelpers.applyHeadIndent` and `applyTextLists`,
  test-only leftovers of an earlier list layout

Their three tests go with them.

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

* fix(ios): address review of the SwiftUI conventions pass

- CodeBlock and Code no longer store `.monospaced` in their initializer.
  Since weight and design now layer over the lower theme's font, that
  default made `CodeBlock().foregroundStyle(.red)` reset a serif code
  font. A `defaultFontDesign` hook applies it only to the element's own
  font spec, so `CodeBlock().font(size: 14)` stays monospaced
- `italic(false)` removes an inherited italic: the family's upright face
  when it has one, else the descriptor without the trait
- the font-resolution warning and doc comment name the live modifiers
- `border(width:)` resizes a border a lower layer colored, so the
  deprecated `borderWidth` has a non-deprecated replacement
- tests that a table-cell tap and VoiceOver activation with only
  `openURL` set open through it; `TableAttachmentView.openLink` is the
  seam the tap handler calls

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(ios): give custom spoiler overlays the text's baseline and segment order (#894)

* feat(ios): give custom spoiler overlays the text's baseline and segment order

A custom overlay that shows the text through (blur, pixelation) drew
`concealedText` at its own origin, and the slice carries inline styling
only. With a theme line height taller than the font the glyphs landed a
few points above the real ones, so the text jumped when the overlay was
removed at the end of the reveal; the README's blur sample had the
same drift.

`SpoilerOverlayView` now carries `baseline`, taken from the TextKit 2
segment the text view laid out, and `concealedTextImage()` draws the
slice with CoreText at that baseline, applying per-run baseline
offsets, so nothing shifts on removal. It also carries `segmentIndex`
and `segmentCount` so a wrapped spoiler can reveal line by line.

The README documents the new API, switches the blur sample to it, and
notes that an opaque overlay only blends in when the theme's spoiler
background matches what is actually behind the text view.

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

* fix(ios): give a spoiler overlay its segment order before adding it

A new overlay received segmentIndex and segmentCount only after
addSubview, so a subclass reading them from didMoveToSuperview or
didMoveToWindow saw the defaults. Set every property before the view
joins the text view, and keep refreshing the order on reused overlays,
whose place changes when the line above them rewraps. Both properties
now name their type.

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

* fix(ios): resolve spoiler overlay colors for the view's own traits

concealedTextImage() handed CoreText color.cgColor, which resolves a
dynamic color against UITraitCollection.current. Inside layoutSubviews
that is the overlay's trait collection, but a subclass rendering from
animateReveal could pick up whatever was current last and draw the
slice in the other appearance. Resolve against the view's
traitCollection instead.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Maksymilian Galas <max@infinitypi.co>

* fix(web): prevent native props from reaching the DOM (#818)

Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>

* perf(ios): read textStorage instead of copying attributedText per a11y element (#898)

UITextView.attributedText returns a fresh copy of the whole text on
every call. blockquoteAnnouncementForRange: read it three times per
element and frameForRange: once more, so building the accessibility
elements cost O(elements x document length). textStorage is the same
text without the copy.

On the example app (Release, iPhone 17 Pro simulator), the first
accessibilityElements call on a 1,600-section document (11,202
elements) drops from 46.6 s to 1.7 s. Element labels, traits, values,
hints, frames and rotor items are unchanged.

Fixes #897

Co-authored-by: Matt Voska <voska@users.noreply.github.com>

* perf(android): hand the rendered buffer to the text view uncopied in React Native (#889)

* fix(ios): yoga measurement handles incorrectly with number of lines set (#909)

* perf(ios): render and draw long documents faster and in less memory (#891)

* perf(ios): cache font trait resolution

Resolving bold or italic read font descriptors and walked the family's
faces on every strong or emphasis run, about 80 µs each; a document of
500 bold and italic paragraphs spent two thirds of its render there.
Results are remembered per font behind a lock, and that document now
renders in a quarter of the time.

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

* perf(ios): skip the baseline-shift pass on documents without one

applyShifts walked every attribute run of the document twice, even when
nothing was superscript or subscript: 50 to 75 ms on long documents,
on every render. The render context notes when either renders and the
pass returns early otherwise, for the document and for table cells.

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

* perf(ios): read the document's last character in place

`output.string.hasSuffix("\n")` bridges the whole backing store to a
Swift String, 42 µs per 500 KB, and every block renderer asked it, so a
long list paid a copy of the document per item. The newline helpers
read the last character through mutableString, and the spacer check in
ListItemRenderer scans its range against CharacterSet.newlines instead
of copying it out.

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

* perf(ios): draw the image placeholder once

Every image attachment rendered its own 1×1 placeholder bitmap on every
render; one static image serves them all.

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

* perf(ios): skip renders superseded before they start

Under streaming the serial render queue rendered every intermediate
document in full before it reached the latest one. A render now checks
the newest schedule id before it starts, so a schedule superseded while
queued is dropped instead of rendered and discarded. The id lives in an
OSAllocatedUnfairLock because the queue reads it off the main thread.

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

* perf(ios): draw block chrome from one fragment walk, tiled to the viewport

The decoration drawers asked TextKit 2 for enumerateTextSegments once
per paragraph. That call costs time proportional to the range's position
in the document, so a draw was quadratic: 4 s for a 2000-item list and
3.7 s for a 130 KB document, on the main thread, on every layout pass
and every SwiftUI update. ParagraphLayoutWalker now reads every
paragraph's lines from one enumeration of the layout fragments, which
hand out the same bounds and baselines (verified pixel-identical against
the old output), bounded to the drawn rect and started by bisecting
character offsets, since every point- or rect-based TextKit lookup also
walks from the document start.

The two decoration views were bitmaps the height of the document: +304 MB
and 550 ms of display for a 28k-pt document. They now cover a tile around
the enclosing scroll view's visible region and move when the region
leaves it; outside a scroll view, or for a document shorter than a tile,
they still cover the whole document. A tile that cuts a code block
completes the block through its neighbours so the corners stay put.

styleConfig repaints the chrome only on a real change; updateUIView
assigns it on every pass. The marker drawer previously read only the
first directional run of a mixed-direction first line, which could put
an RTL marker mid-line; it now reads the whole line.

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

* test(ios): add opt-in performance benchmarks

Skipped unless TEST_RUNNER_ENRICHED_MARKDOWN_BENCHMARKS=1 is passed to
xcodebuild test. Measures rendering, first layout and the decoration
display of a windowed text view through XCTest's measure, using only API
that exists on either side of the perf changes, so the same file runs
against any checkout for a before/after comparison.

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

* test(ios): add a before/after benchmark runner

scripts/benchmark.mjs runs the opt-in PerformanceBenchmarks on the
current checkout and, with --base <ref>, on that ref in a throwaway
worktree with the RaTeX vendor files restored, then prints both with the
head/base ratio; --only narrows to one test, --markdown appends the
table to a file, --fail-above turns a ratio into an exit code. Wired as
`bench:ios-native` and described in the README's Development section.

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

* ci(ios): compare benchmarks against the base branch on pull requests

Every pull request that touches the iOS package now runs the opt-in
benchmarks on its head and on the base branch tip, back to back on the
same runner, and fails when any benchmark is more than twice as slow.
Absolute numbers on a shared runner are noise, but a same-runner ratio
still catches order-of-magnitude regressions like the ones the perf
audit found. The job is not required by ci-success until it has proven
quiet.

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

* test(ios): compare benchmark medians and mark each ratio

XCTest's average is at the mercy of one stalled iteration: on a shared
runner the bold-paragraph render came out 1.28x slower with a ±73%
deviation, four normal iterations and one that paused for seconds, and
locally the first iteration of every measurement is a cold outlier. The
runner script now parses the per-iteration values and compares medians,
which put that row back at 0.32x while a real 2.5x regression still
trips the gate.

The report marks each ratio as faster (below 0.8x), within noise,
slower (above 1.25x) or over the failure threshold, with a verdict line
and a legend in the Markdown and ANSI colors on a terminal. The table
lists base before head, build products live under ~/Library/Caches
rather than the temp folder macOS prunes piecemeal, a failed build gets
one clean retry, and the report functions are importable for testing.

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

* ci(ios): comment benchmark results on the pull request

The benchmark job writes its table to a file and a final step, run even
when the 2x gate fails, appends it to the job summary and posts it on
the pull request with gh, editing the previous comment in place. The
job takes pull-requests: write for that; a fork's read-only token only
logs a warning. It also waits for the simulator to finish booting before
the first measured run.

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

* ci(ios): run the benchmarks on demand instead of on every pull request

A dedicated workflow replaces the ios-benchmarks job in ci.yml. It runs
when a pull request gets the `benchmark: ios` label (and on later pushes
while the label stays), when someone with write access comments
`/benchmark ios` on it, or from the Actions tab with a pull request
number. Each way resolves the pull request's base and head, runs the
same comparison, and posts the same comment. Comment- and
dispatch-triggered runs use the workflow file on main, so those two
paths go live once this lands.

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

* ci(ios): address review of the on-demand benchmark workflow

Only benchmark-triggering events may cancel a run in progress: any
comment on the pull request creates a run in the concurrency group and
was cancelling the benchmark before its own job got skipped. The
resolve step captures the pull request's refs into a variable and
rejects empty ones, where a failing gh inside an echo passed unnoticed
and left the base empty. The job times out after an hour, the reaction
to a `/benchmark ios` comment comes before any step that can fail, the
results comment uses --create-if-none, and the runner script prunes a
worktree registration an interrupted run left behind before adding one.

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

* fix(ios): address review of the decoration walk and tiling

The decorator added the text view's content offset to the tile, but a
subview's frame is already in the content's coordinates, so any offset
moved every marker, bar and background; the tile is used as is, with
the container inset accounted for. A code block cut by a tile got its
rect by walking neighbours with insert(at: 0), quadratic in the lines
above the tile; its range now comes from the attribute run and its end
paragraphs from two constant-time lookups. The walker lays out any
fragment TextKit has not laid out before reading it, bisection probes
included, since such a fragment reports a zero frame. The text view
looks for its enclosing scroll view on every hierarchy report and on
layout, as an ancestor moving into a scroll view reaches it only that
way. The benchmark runner rejects a non-numeric threshold.

Also: one shared code-block range lookup for the drawer and the
accessibility builder, which now bounds its list-depth scan to the item
instead of walking every sibling, and an accessibility benchmark.

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

* ci(ios): post every benchmark run as a new comment

The publish step edited the bot's last comment in place, which kept the
table at its old spot in the thread and sent no notification: a run on
a later push refreshed a comment from days earlier and nobody saw it.
Every run now adds its own comment at the bottom of the thread, and the
table's header names the head commit it measured (with a -dirty suffix
on a local run with uncommitted changes) so several tables on one pull
request stay apart. Comment- and dispatch-triggered runs read the
workflow on main, so those two paths change once this lands.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* chore: release v1.1.0 (#910)

* fix(input): ensure taps on rich text input don't trigger parent onPress (#884)


Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ios): move spoiler overlays on layout and pass the tap point to reveals (#895)

A re-layout used to replace every overlay whose frame changed, so a
custom effect lost its state whenever content above it changed height,
such as an image finishing loading: particles restarted, anything
mid-motion snapped. Overlays are now keyed by spoiler range and segment
ordinal. A segment that keeps its range, size and baseline moves its
view, and one that changed is replaced.

Within one text, the same range and size means the same glyphs, so the
content is only compared after the text view replaces its text, against
the slice the overlay was built from. That keeps layout passes and
re-renders at the old cost, and fixes new text laid out at the same
place and size keeping the old overlay and its stale text.

`SpoilerOverlayView.revealPoint` is set from the tap before
`animateReveal` runs, in the view's coordinates, so an effect can wipe or
ripple out from the reader's finger. It is nil for a programmatic reveal,
and a second tap during the reveal leaves it unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ios): give each list item's marker array its own hash (#913)

Each list item stores its markers as an NSArray attribute value. NSArray's
-hash is its count, so every one-marker array hashes to 1, while the
descriptors inside compare by identity, so no two of those arrays are equal.
UIFoundation uniques attribute dictionaries in a hash table, so adding item
N's marker probes past every earlier item's dictionary: rendering is
quadratic in the number of list items, and memory grows with it.

Store the markers in an NSArray subclass whose -hash is the hash of the
item's own (last) marker. It is still an NSArray, so ListMarkerDrawer and
TaskListTapUtils read it unchanged, and arrays that are equal still hash
equal.

Co-authored-by: Matt Voska <voska@users.noreply.github.com>

* fix(ios): pass accessibility labels into GFM blockquote children (#919)

A list inside a blockquote crashed any accessibility walk with
flavor="github": the quote's child views never received
accessibilityLabels, so formatListAnnouncement returned nil and it was
inserted into an NSMutableArray.

Give ENRMBlockquoteContainerView an accessibilityLabels property, set it
from the root view, hand it to text/table/math/nested-quote children and
push changes to existing children. Also nil-check the list announcement
like the blockquote announcement next to it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ios): account for baseline offset when drawing underline and strikethrough (#916)

TextKit 1 does not incorporate NSBaselineOffsetAttributeName when drawing
underline and strikethrough decorations. When lineHeight is larger than
the fonts natural line height, applyBaselineOffset shifts glyphs upward
to center them within the line fragment, but decorations remain anchored
to the original unshifted baseline. With sufficient shift the underline
intersects descenders and iOS breaks the stroke around glyph shapes,
producing a dotted/fragmented appearance.

Override drawUnderlineForGlyphRange: and drawStrikethroughForGlyphRange:
in TextViewLayoutManager to read NSBaselineOffsetAttributeName from the
text storage and add it to the baselineOffset parameter passed to super.
This keeps decorations aligned with the shifted glyphs.

The fix is paint-only: no layout, measurement, or view-height changes.
When the attribute is absent (shift is 0), the draw methods pass the
original arguments through unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(android): add highlight support to enriched-markdown-android (#844)

* fix(ios): make attributed text explicitly set NSOriginalFont (#929)

* fix(ios): make attributed text explicitly set NSOriginalFont

* fix: address cr comments

* chore: release v1.1.1 (#934)

* docs: add the Docusaurus documentation site(#713)

* feat(docs): add docusaurus documentation site
* docs: add agent guide for the docs site
* feat(docs): add topbar banner, add gtm integration (#540)
* feat: updated documentation
* feat: update platform badge
* feat: introduced file structure; wrote some docs; fixed configs
* feat: updated first documentation page
* docs: finished first page of documentation
* docs: update configs
* docs: polished speech
* docs: fundamentals done
* docs: checkpoint WIP before merging main
* docs: correct underline (Android/RN) and web autolink cells
* docs: add LivePreview editable playground and wire first-project examples
* feat: finished react native basics section
* docs: react native api reference for two main components (#722)
* docs: finished styles docs (#733)
* docs: finished v1 docs (#735)
* docs: finished element structure for rn
* feat: rich text formatting
* docs: finished misc, last cleanups
* docs: up to date docs (#737)
* docs: up to date documentation
* feat: update ci
* docs: polishing documentation 1
* docs: keep both InteractiveExample panes mounted for SSR
* feat: harden docs ci (#838)
* docs: post review cleanup 1 (#840)
* docs: moved flavour page to react native
* docs: replace ratex link with their page
* docs: moved rn-related sections to rn sidebar
* docs: add use memo to all docs styles
* fix: lefthook link nowarn ignored files flag
* docs: contrubiting section
* docs: roadmap page
* docs: known limitations page
* docs: moved accessibility and rtl to separate section
* docs: added toc dots
* feat: update the web information to drop react-native-web from it
* fix: fix favicon on dev serwer
* fix: finished rn docs
* docs: android native docs v1 (#845)
* docs: update android api reference with new copose names (#876)
* docs: ios documentation v1 (#880)
* docs: update ios api reference to match the new api (#881)
* docs: native documentation polished (#882)
* docs: ios documentation polished
* docs: polished android docs
* docs: fixed ci; fixed routing
* docs: polish documentation according to last review (#914)
* fix: post merge update
* feat: polished documentation according to last review; hid ios and android docs
* docs: document the input press props and bump to 1.1.1
* docs(android): the highlight renderer landed
* docs: drop static/CNAME, correct the deploy notes
* docs: correct the press handler type to unknown

---------

Co-authored-by: Szymon Halski <halskiszymon@gmail.com>
Co-authored-by: Sullyvahnn-v2 <artur.wojnar@samorzad.agh.edu.pl>
Co-authored-by: Artur Wojnar <arturwojnar@Arturs-MacBook-Pro.local>

* fix(ios): classify writing direction beyond the BMP with CoreText (#907)

* fix(ios): classify writing direction beyond the BMP with CoreText

The first-strong resolver decided from a table of three BMP blocks, so a
letter beyond the BMP (Adlam, Old Hungarian) read as left-to-right and a
paragraph forced right-to-left with U+200F stayed neutral. The table keeps
deciding for the BMP, where it is exact; a letter beyond it is classified
by CoreText, and U+200E, U+200F and U+061C count as strong marks.

Leaving paragraphs .natural for TextKit to resolve, as proposed in #874,
does not work: TextKit right-aligns such a paragraph but keeps its indents
on the app's side, so the marker column ends up on the wrong side. A test
pins that down.

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

* refactor(ios): classify letters beyond the BMP from the range table

Unicode reserves U+10800-10FFF and U+1E800-1EFFF for right-to-left
scripts, and the two ranges are exactly the lead surrogates D802-D803 and
D83A-D83B. Two more entries in the table therefore classify a letter
beyond the BMP without laying out a CoreText line: the answer is the same
for every letter, and a paragraph that starts with one resolves in about
1 microsecond instead of 14 on the simulator. A combining mark from those
ranges that comes before any letter now resolves right-to-left too.

The tests gain a script under each of the four lead surrogates, the
left-to-right scripts next to the ranges and an emoji with a variation
selector. Comments are cut down to what the code cannot say, which drops
the two that review questioned.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf(android): position inline code backgrounds from the drawing layout (#899)

* refactor(android): split compose style patches into one file per element (#900)

* ci(android): compare the display benchmark against main on demand (#908)

* feat(android): let apps supply custom spoiler overlays (#903)

* feat(android): add LaTeX math rendering as an optional plugin (#873)

* feat: add native link pills (#920)

* feat: add opt-in native link pill presentation (#862)

* feat: allow link variants to override font family

* fix: cleanup; docs: storybook

* feat: add opt-in native link variant pills

* fix: render iOS pill attachment only at its first source character

* fix: track pill glyph continuations without recursive layout queries

* perf: skip iOS pill glyph copies and range scans for ordinary text

* fix: address native link pill review findings

* refactor(link-pill): render pills as a native attachment and add per-link content

Rework the link pill presentation on top of the original implementation.

iOS: a pill link is now a single attachment character laid out and drawn by
TextKit, like images and inline math, instead of hidden link characters
substituted through a custom text storage. The link's own text is kept on the
attachment and put back wherever text leaves the view: copy, Markdown/HTML/RTF
export, accessibility, and the system selection actions.

Android: pills prepare their width per layout thread through a shared helper,
are set directly while rendering, and are redrawn through a span change when an
icon arrives or a spoiler over them is revealed.

Both platforms:
- `linkPillContent` prop: per-link label and icon keyed by exact URL
- `pill` is a nested object on the native link variant
- remote icons through the existing image pipeline, with bounded caches and a
  retry window for failed sources
- links holding an image, inline math, a hard line break or a spoiler stay
  ordinary links; a pill inside an unrevealed spoiler is hidden
- measurement caches account for link variants and pill content

Also makes the shared Android image downloader report a request OkHttp rejects
as a failed download instead of throwing into the render.

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

* docs(storybook): add link pill story

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

* test(ios): run the link pill tests in the example test target

Add the pill attachment tests and the library header paths they import to
EnrichedMarkdownExampleTests, and let the target inherit only the pods' search
paths: linking the pods into the test bundle as well loaded every library class
twice.

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

* docs: document link pill content and behavior

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

* chore(example): match the Podfile checksum to the test target change

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

* chore: keep Android unit tests out of the npm package

The android folder is published whole, so android/src/test shipped with it.

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

---------

Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Gregory Moskaliuk <mosckalyuck@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): keep list markers on the baseline when an item starts with an attachment

TextKit places an attachment glyph at the bottom edge of its bounds, not on
the baseline. The marker drawer read the first glyph's location as the
baseline, so an item that starts with a link pill, an inline image or inline
math drew its bullet, number or checkbox too low by however far the
attachment hangs below the text.

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

* feat(link-pill): add pill.lineHeight to keep stacked pills apart

A pill is as tall as its font's line plus its padding and border, and a
line grows to fit it, but only to the pill's own height. Pills on
consecutive lines therefore touch whenever the line height leaves no
room, which it rarely does with the default padding.

`pill.lineHeight` is a floor for the block that holds the pill: a
paragraph, list item, heading or quote gets lines at least that tall, on
every line, so its spacing stays even. It only raises the block's own
`lineHeight`, scales with the font like the block line heights do, and
is unset by default, which leaves every existing layout as it was.

Each platform folds it into the step where a block already sets its line
height. On Android the floor takes the flags of the block's own line
height span, so that it applies before the spans that add a margin to a
line.

For text that streams in, the docs recommend the block's own
`lineHeight` instead: a line height that depends on whether a pill is
there changes the moment a link becomes one.

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

* test(link-pill): remove the native unit tests

Drops LinkPillSpanTest.kt (Android) and ENRMLinkPillAttachmentTests.mm
(iOS), and with them the wiring that existed only for those tests: the
example's test target goes back to how main sets it up (Podfile,
Podfile.lock, Xcode project, the iOS tests README), and the Android
library no longer includes resources in unit tests.

The Jest tests for pill style and content stay. So do the harness smoke
tests from main, which keep both native test jobs running.

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

* fix(link-pill): drop null from the pill variant type

A variant either sets a pill or leaves it out; null was a third way to
say the same thing as false.

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

* fix(android): release a pill's icon slot when its download fails

A remote icon that failed to load left its slot reserved for as long as the
document stayed static, so the pill showed a blank gap in front of its label.
The cache now reports the failure and the span gives the slot up, reflows its
hosts and has the component (or a table cell) re-measure, as iOS already did.

Also resolve pill content through one fallback helper: LinkPillStyle holds a
LinkPillContent, and the span and both ReadableMap parse sites go through its
orElse / fromReadableMap instead of spelling the chain out twice.

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

---------

Co-authored-by: Julius Marminge <jmarminge@gmail.com>
Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(link-pill): add optional icon tint (#921)

* feat(link-pill): add optional icon tint

`pill.iconTintColor` replaces the colors of a pill's icon and keeps its alpha;
omitting it keeps the image's own colors. It applies to local and remote icons
and leaves the cached image untouched.

The option and its Android and Jest tests come from #863, ported to the nested
`pill` configuration. Android sets the tint on the shared icon paint for every
icon, tinted or not, so one pill cannot tint the next.

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(link-pill): keep the variant tint off per-link icons

The variant's `pill.iconTintColor` tinted whatever icon a pill showed, so an
icon supplied through `linkPillContent` (an avatar, say) became a silhouette
in the tint color.

The variant tint now applies only to the variant's own icon. A
`linkPillContent` entry can carry its own `iconTintColor`: it tints the entry's
icon, or recolors the variant's icon for that one link when the entry has no
icon.

Also covers transparent and half-transparent tints in the iOS tests, documents
the rule, and adds an icon with a tint control to the Storybook pill story.

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

---------

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(ios): add long-press menus for links (#923)

* feat(ios): add long-press menus for links

`linkContextMenuItems` gives links a native menu on long press (iOS 17+). It is
keyed by URL pattern and matched like `linkVariants`, so one entry covers every
link of a kind:

  linkContextMenuItems={{
    '^https://example\\.com/files/': [
      { text: 'Copy path', icon: 'doc.on.doc', onPress: ({ url }) => copy(url) },
    ],
  }}

A link with a menu shows it instead of `onLinkLongPress` and the system
preview; other links keep their long-press behavior. The menu is titled with
the link's text or its pill label. It works in text, blockquotes and table
cells, where it lifts only the link.

Native hears about the menus only when what they show changes, not when
callbacks do. Android accepts the prop and ignores it.

Based on #865, reworked from menus keyed by exact URL to item lists keyed by
pattern.

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(ios): lift a pill as itself when its link menu opens

Without a preview UIKit greys the pressed text out, which hides a pill's own
look. A pill with a menu is now shown as its own image on a small platter in
the background color, as it already was in table cells.

Plain links keep the system highlight.

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

* fix(ios): lift only the link when its menu opens in a table

Three problems with the link menu in tables:

- UIKit hides the view a preview is made of while the menu is open. The
  link was lifted through a visible path into the grid, so the whole
  table vanished behind the menu.
- A preview is drawn only from a view that is on screen, so a detached
  view stays blank until the menu appears. The link is now lifted as an
  image of itself laid over the grid, removed when the menu ends.
- UIKit replaces a nil identifier with one of its own, so the table's
  own menu was taken for a link menu: it lifted a sliver of the grid, or
  the last link pressed. The stored link frame now tells them apart.

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

* fix(ios): lift a pill as itself when its menu opens in text

A pill with a menu in a paragraph, list or blockquote was lifted by
UIKit as a text item: a padded box in the pill's colour first, then a
jump to a platter of our own once the menu opened.

The text view now presents that menu itself and gives UIKit none for
such a pill, so UIKit builds no box. The pill is lifted as an image of
itself laid over the text, and draws nothing in the text meanwhile, as
it would show through its lifted copy. New text takes the image down.

The table lifts its links the same way, so both now share
ENRMLinkMenuLift. The menu protocols live with the menu utilities, which
no longer need to know the text view.

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

---------

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(input): expose link destination in StyleState so apps can display link target (#883)

* feat(input): expose link destination in StyleState

onChangeState and context-menu styleState.link now include destination
when the caret is inside a manual link, including at the half-open range
end index where iOS often places the caret.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(example): show StyleState JSON in the playground and add Maestro flow

The playground prints the latest onChangeState payload as JSON, and the
flow checks that tapping into a link reports its destination.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remerge link handling code

* refactor(android): route the context-menu link dialog through setLinkUrl

The dialog looked up the link with rangeOfType at the selection start and
applied it with addLinkDirect, bypassing the shared link-at-selection rule.
Use linkForSelection and setLinkUrl like JS setLink and the iOS link prompt,
and remove the now-unused applyLinkToRange and addLinkDirect.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(android): use onLinkClick in the Compose docs (#949)

* chore: ignore local .claude directory

* feat(web): add FormattingStore model for the web input

* feat(web): add range edit adjustment for the web input

* feat(web): apply text edits to the formatting store

* feat(web): add style toggling and atomic link selection to the store

* refactor(web): split web input model types into themed files

* fix: apply code review

* refactor(web): extract the sorted-range binary search

sortedInsertionIndex is one instance of the same lower-bound search the
other stores need, so it moves into a reusable helper rather than being
rewritten per store.

Created with usage of AI tools

---------

Co-authored-by: Maksymilian Galas <max@infinitypi.co>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Gregory Moskaliuk <mosckalyuck@gmail.com>
Co-authored-by: Jakub Lyczko <jakub.lyczko1@gmail.com>
Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Matt Voska <3444419+voska@users.noreply.github.com>
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Co-authored-by: Jan Nowakowski <56261019+jnowakow@users.noreply.github.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Harry Yu <harry@wanderlog.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Trần Đình Huy <kingonwork@gmail.com>
Co-authored-by: Szymon Halski <halskiszymon@gmail.com>
Co-authored-by: Sullyvahnn-v2 <artur.wojnar@samorzad.agh.edu.pl>
Co-authored-by: Artur Wojnar <arturwojnar@Arturs-MacBook-Pro.local>
Co-authored-by: Julius Marminge <jmarminge@gmail.com>
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
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.

3 participants