Repository navigation
feat: add opt-in native link pill presentation - #862
hryhoriiK97 merged 17 commits into
Conversation
eszlamczyk
left a comment
There was a problem hiding this comment.
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.
-
API / native TextKit usage. ENRMPillLayout omits
usesFontLeading = NOmaink it the only TextKit stack that violates the "must be set to NOrule (ENRMViewFreeMeasurement.h`) -
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.
-
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.
-
API shape, currently the API is very unpleasant for end user to work with, more informations are in the relevant inline comments
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
b28c319 to
3ba9ea7
Compare
eszlamczyk
left a comment
There was a problem hiding this comment.
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
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
left a comment
There was a problem hiding this comment.
@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.
…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>
|
@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 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
What I'd ask from you
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>
83ba5ba
into
software-mansion:feat/link-pills
|
Update on how this lands. I merged this PR into From here:
|
* 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>
…#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.
* 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>
What/Why?
Opt-in pill presentation for Markdown links on iOS and Android. A link whose
markdownStyle.linkVariantsentry setspillis 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.
pill: trueuses the defaults;pill: { label, iconUri, borderRadius, paddingHorizontal, paddingVertical, borderWidth, borderColor, maxWidth }customizes it; omitted,falseornullkeeps an ordinary link. Colors, underline and font family stay on the link variant.linkPillContentprop sets the label and icon of individual links by exact URL. It is content, not style, somarkdownStylecan stay a stable object while it changes. Per-link content wins over the variant'spill.label/pill.iconUri, which win over the link text.http(s)sources load through the same pipeline as Markdown images, usingimageRequestHeaders, and the pill keeps room for them while they load. A source that fails shows no icon and is retried after 30 seconds.maxWidth. It works in paragraphs, headings, lists, blockquotes and table cells.pillandlinkPillContent.API
Docs:
docs/MENTIONS.md(pill section),docs/API_REFERENCE.md(linkPillContent),docs/WEB.md,docs/MACOS.md.Implementation
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 byENRMLinkPillTextwherever text leaves the view.ENRMMarkdownTextViewdoes the same for the system selection actions. This replaces the custom text storage and glyph substitution of the first version.LinkPillSpanis aReplacementSpanover 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.pillis a nested object on the native link variant;linkPillContentis passed likeimageRequestHeaders. Measurement caches on both platforms account for link variants and pill content.ImageDownloadernow 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.android/src/testis excluded from the npm package (filesinpackage.json). Theandroidfolder is published whole, so the unit tests shipped with it; a dry run ofnpm packnow 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 npmfilesexclusion.link-pill-style.test.ts(style normalization, content normalization and equality).LinkPillSpanTest.kt.ENRMLinkPillAttachmentTests.mm, now registered in the example's test target and run by CI.Not covered: drag-and-drop of a selection containing a pill on iOS, and Maestro E2E. After
mainwas merged into the branch, the on-device check was repeated only for rendering on Android.PR Checklist
Original description (written for head 7781ab1, before the rework)
What/Why?
Extend the existing
markdownStyle.linkVariantsconfiguration with optional iOS/Android pill presentation for explicit Markdown links. Consumers supply label, local icon URI and geometry throughpill: { ... };pill: trueuses 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.pilldefaults 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
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 = NOnow 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-successcheck 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 excludesENRMLinkPillTextStorageTests.mmfrom 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.
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.tsplus its original path andMobile Checkplus$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 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-25659ba5df5492476181ecbe1ba573a1c163c4da3eaeea0431f30ed5ea235876319. 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_FILEresource 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
🤖 Generated with Claude Code