Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,34 @@ const renderers: RendererOverrides = {

Inside a selection run, links are native tappable ranges and the `link` renderer does not run. Taps arrive at `onLinkPress({ href, blocked, start, end })`. Without a handler, live links open through `Linking.openURL` and blocked ones do nothing.

`classifyBlock` marks a block `'standalone'` so it gets its own selection scope and renderer. Use it for blocks that own a competing gesture. Images and spoilers are standalone already. Give it a stable identity; the document is resegmented when it changes.
A renderer override changes what a node draws, not which selection run it lives in. To keep a real card *inside* the sweep, claim it through `embed`: the node projects as one placeholder character, the host reserves your declared size there and reports where it landed, and your element is overlaid on that space. Selecting across the card copies its exact markdown; `copy-text` substitutes the `text` you declare.

```tsx
import type { EmbedRenderer } from 'react-native-selectable-markdown';

// Module scope or useCallback: a changed claim resegments and reprojects.
const embed: EmbedRenderer = (node, { topLevel }) =>
node.kind === 'link' && /^cards:/.test(node.href)
? {
width: topLevel ? 320 : 160, // declared, not measured: sizing is layout-affecting
height: 88,
text: '[cards]', // what copy-text shows for the card
render: (node) => <CitationCards href={node.href} />,
}
: undefined;

<SelectableMarkdown source={md} options={options} embed={embed} />
```

A block-level embed may be any height; an inline one shares a line with prose, so keep it chip-sized (on iOS a line cannot outgrow its paragraph's leading). `topLevel` is false for a node nested under a list item or blockquote, where a full-column reservation would overflow the leading margin. The card owns taps inside its bounds, so a long-press on it starts no selection.

`classifyBlock` marks a block `'standalone'` so it gets its own selection scope and renderer. Use it for blocks that own a competing gesture and should end the sweep rather than flow through it. Images and spoilers are standalone already. Give it a stable identity; the document is resegmented when it changes.

```tsx
import type { ClassifyBlock } from 'react-native-selectable-markdown';

const classifyBlock: ClassifyBlock = (node) =>
node.kind === 'link' && /^cards:/.test(node.href) ? 'standalone' : undefined;
node.kind === 'link' && /^widget:/.test(node.href) ? 'standalone' : undefined;
```

## Architecture
Expand Down Expand Up @@ -244,7 +265,7 @@ selection offsets ──► mapSelectionToSource ──► exact source span ─
- [docs/BENCHMARKS.md](docs/BENCHMARKS.md) and [docs/PERFORMANCE.md](docs/PERFORMANCE.md): measurements, cost model, roadmap.
- [docs/FABRIC-PLAN.md](docs/FABRIC-PLAN.md): design retrospective of the new-architecture port.

## Status (0.10.x)
## Status (0.11.x)

| Area | Where it stands |
| --- | --- |
Expand All @@ -253,6 +274,7 @@ selection offsets ──► mapSelectionToSource ──► exact source span ─
| Selection and copy | Exact source ranges, property-tested. Code blocks, tables and rules flow through runs; images and spoilers are standalone. |
| Copy menu | Copy Text and Copy Markdown. Custom items need iOS 16+; iOS 13.4 to 15 gets the system menu only. |
| Selection host | Fabric only (`react-native >= 0.82`). CI compiles the C++ against real renderer headers and the Swift against the iOS SDK, but there is no example app yet, so on-device behaviour is reviewed rather than exercised. |
| Embeds | The `embed` prop: a claimed node flows through its run as one placeholder, the host reserves its declared size and reports the rect (`onEmbedLayout`), JS overlays the element. Removed in 0.10.0, restored in 0.11.0. Reviewed on-device like the host itself. |
| Android selection preservation | Not implemented, and the largest known gap. Each streamed text swap drops the selection. iOS preserves it. |
| Benchmarks | Node harnesses in `bench/`. On-device numbers are planned. |
| Example app | Planned. |
Expand Down
59 changes: 59 additions & 0 deletions android/src/main/java/com/selectablemarkdown/EmbedLayoutEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.selectablemarkdown

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event

/**
* `onEmbedLayout` — fired per embed, after layout, with the rect the host
* reserved for it (host coordinates, dp).
*
* Everything structural here is inherited from `SelectionActionEvent`'s
* reasoning, which is the authoritative copy: the `Event`-object dispatch
* (posted to the dispatcher `UIManagerHelper.getEventDispatcherForReactTag`
* resolves, so one path serves both architectures), and the `top…` spelling
* (matched verbatim on paper, left alone by Fabric's `normalizeEventType`,
* and what the codegen'd view config keys the event under).
*
* `canCoalesce` is false here for a DIFFERENT reason than the other two
* events. A layout report is latest-wins, so coalescing would be
* semantically fine — but the framework coalesces by `(eventName, viewTag,
* coalescingKey)`, the key is a Short, and reports for DIFFERENT embeds of
* one host must never merge, so correctness would hang on an id-to-Short
* mapping. The host dedupes at the source instead (`reportEmbedRects` emits
* only rects that moved), which bounds the event volume better than
* coalescing could and keeps this class as boring as its siblings.
*/
internal class EmbedLayoutEvent(
surfaceId: Int,
viewId: Int,
private val embedId: Int,
private val x: Float,
private val y: Float,
private val width: Float,
private val height: Float,
) : Event<EmbedLayoutEvent>(surfaceId, viewId) {

override fun getEventName(): String = EVENT_NAME

override fun canCoalesce(): Boolean = false

/**
* `embedId` is JS's identifier for the embed (its index into the
* `embeds` prop as sent), echoed verbatim — it is what JS routes on,
* bounds-checked there like `pressableId`. The rect is in the host
* view's coordinate space, dp, matching how JS positions the overlay.
*/
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putInt("embedId", embedId)
putDouble("x", x.toDouble())
putDouble("y", y.toDouble())
putDouble("width", width.toDouble())
putDouble("height", height.toDouble())
}

companion object {
const val EVENT_NAME = "topEmbedLayout"
}
}
11 changes: 11 additions & 0 deletions android/src/main/java/com/selectablemarkdown/RunAttributedText.kt
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ object RunAttributedText {
text: String,
spec: Spec,
decorations: RunDecorations.Spec = RunDecorations.Spec.EMPTY,
embeds: RunEmbeds.Spec = RunEmbeds.Spec.EMPTY,
): Spannable {
val out = SpannableString(text)
if (text.isEmpty()) return out
Expand Down Expand Up @@ -240,6 +241,16 @@ object RunAttributedText {
RunTextMeasure.configurePaint(paint, RunTextMeasure.baseTextSizeSp(text, spec))
RunDecorations.applyLayoutSpans(out, decorations, paint)
}

// Embed reservations last, though the position is symmetry rather
// than necessity: a ReplacementSpan supplies its metrics through
// `getSize` during measurement, so its insertion order relative to
// the LineHeightSpans above is immaterial — `chooseHeight` always
// runs after the glyph metrics are in. What DOES depend on order is
// among the LineHeightSpans themselves: the embed-height `lineHeight`
// attribute JS emits after the base one is what finally sizes the
// placeholder's line (see RunEmbedSpan for the whole story).
RunEmbeds.applySpans(out, embeds)
return out
}
}
Expand Down
188 changes: 188 additions & 0 deletions android/src/main/java/com/selectablemarkdown/RunEmbeds.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package com.selectablemarkdown

import android.graphics.Canvas
import android.graphics.Paint
import android.text.Spannable
import android.text.style.ReplacementSpan
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
import com.facebook.react.uimanager.PixelUtil

/**
* The `embeds` prop: reserved rectangles for one run — each a
* `width` × `height` hole in the text layout at a U+FFFC placeholder
* character JS projected there, over which the JS side absolutely positions
* a consumer's React view. See src/view/runEmbeds.ts for where the entries
* come from and docs/SELECTION.md for the wire contract.
*
* The host never learns what an embed IS. It reserves the space (the
* layout-affecting half, applied to the spannable here as a
* `RunEmbedSpan`), reports where the space landed (`onEmbedLayout`, from
* `SelectableRunHostView.reportEmbedRects`), and echoes `embedId` back —
* the same division of knowledge `pressables` uses for hrefs.
*
* Parsing follows `RunAttributedText.parse` exactly: total (malformed
* entries are skipped, never thrown), on arrival (a ReadableArray is
* bridge-owned memory), into plain values. Dimensions stay in dp until the
* point of use, like every other prop.
*/
object RunEmbeds {

internal data class Embed(
/** UTF-16 offsets into the run text, end-exclusive; the wire
* contract is `end == start + 1` (one U+FFFC), enforced in `parse`. */
val start: Int,
val end: Int,
/** JS's identifier for the embed, echoed verbatim in the event. */
val embedId: Int,
val widthDp: Float,
val heightDp: Float,
)

class Spec internal constructor(internal val embeds: List<Embed>) {
companion object {
val EMPTY = Spec(emptyList())
}
}

fun parse(source: ReadableArray?): Spec {
if (source == null || source.size() == 0) return Spec.EMPTY
val parsed = ArrayList<Embed>(source.size())
for (index in 0 until source.size()) {
if (source.getType(index) != ReadableType.Map) continue
val entry = source.getMap(index) ?: continue
val embed = parseEntry(entry) ?: continue
parsed.add(embed)
}
return if (parsed.isEmpty()) Spec.EMPTY else Spec(parsed)
}

private fun parseEntry(entry: ReadableMap): Embed? {
val start = optInt(entry, "start") ?: return null
val end = optInt(entry, "end") ?: return null
val embedId = optInt(entry, "embedId") ?: return null
val width = optFloat(entry, "width")
val height = optFloat(entry, "height")
// One placeholder character, a non-negative id, and a positive size:
// anything else — including the 0.0 unset sentinel the codegen
// struct documents — reserves nothing.
if (start < 0 || end != start + 1 || embedId < 0) return null
if (width <= 0f || height <= 0f) return null
return Embed(start, end, embedId, width, height)
}

/**
* The layout-affecting half, applied to the spannable the one builder
* produced. Each valid entry replaces its placeholder's glyph with a
* fixed `width` × `height` box that draws nothing — the overlay paints.
*
* THE CHARACTER GUARD IS THE SKEW GUARD: a span is only set where the
* text really carries U+FFFC. Offsets were computed against the text JS
* sent, and under prop skew — or a malformed entry from a newer JS —
* they can point at prose; replacing a real character with an invisible
* box would corrupt what the reader sees, where skipping the entry only
* costs the reservation.
*/
internal fun applySpans(out: Spannable, spec: Spec) {
if (spec.embeds.isEmpty()) return
val length = out.length
for (embed in spec.embeds) {
if (embed.start >= length || embed.end > length) continue
if (out[embed.start] != PLACEHOLDER) continue
out.setSpan(
RunEmbedSpan(
PixelUtil.toPixelFromDIP(embed.widthDp).toInt().coerceAtLeast(1),
PixelUtil.toPixelFromDIP(embed.heightDp).toInt().coerceAtLeast(1),
),
embed.start,
embed.end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
}

/** U+FFFC OBJECT REPLACEMENT CHARACTER — `EMBED_PLACEHOLDER` in
* src/selection/mapSelection.ts; the two must agree or every entry
* fails the character guard above. */
internal const val PLACEHOLDER = ''

private fun optFloat(entry: ReadableMap, key: String): Float =
if (entry.hasKey(key) && entry.getType(key) == ReadableType.Number) {
entry.getDouble(key).toFloat()
} else {
0f
}

private fun optInt(entry: ReadableMap, key: String): Int? =
if (entry.hasKey(key) && entry.getType(key) == ReadableType.Number) {
entry.getDouble(key).toInt()
} else {
null
}
}

/**
* The reservation itself: a fixed-size, draw-nothing box in place of the
* placeholder glyph.
*
* A `ReplacementSpan` is a `MetricAffectingSpan`, so `Layout.getDesiredWidth`
* and `StaticLayout` both account for it — the measure paths and the
* `TextView` read the one cached spannable this span was set on, which is
* what keeps the measured hole and the drawn hole the same hole with no new
* agreement machinery.
*
* HOW THE HEIGHT ACTUALLY LANDS. `getSize` asks for the height as ascent
* (the box sits on the baseline, rising `heightPx` above it), but the final
* line extents belong to the `LineHeightSpan`s: JS sends a `lineHeight`
* attribute equal to the embed height over this same character, emitted
* AFTER the base attribute, and `RunAttributedText.build`'s insertion-order
* rule makes that `RunLineHeightSpan` the last word on the placeholder's
* line. Its surplus branch redistributes extra room evenly above and below
* the baseline, so the baseline may sit mid-line — which is why
* `reportEmbedRects` anchors the reported rect on `getLineTop`, never on
* baseline arithmetic against this span's ascent.
*
* IMMUTABLE, AND THAT IS LOAD-BEARING: the spannable carrying this span is
* shared across the measure thread and the widget (`RunLayoutCache.styledText`
* documents the boundary), so a span that cached a measured rect in a field
* would be a data race. Geometry is computed from the `Layout` at report
* time instead. A data class, so the cache key's spannable inputs stay
* value-comparable.
*/
internal data class RunEmbedSpan(
private val widthPx: Int,
private val heightPx: Int,
) : ReplacementSpan() {

override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?,
): Int {
if (fm != null) {
fm.ascent = -heightPx
fm.top = fm.ascent
fm.descent = 0
fm.bottom = 0
}
return widthPx
}

override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint,
) {
// Nothing. The consumer's React view is overlaid on the reported
// rect; this span only holds the space open.
}
}
15 changes: 12 additions & 3 deletions android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import java.util.concurrent.atomic.AtomicBoolean
*
* THE KEY CAPTURES EVERYTHING THE BUILD READS, and that list is load-bearing:
*
* - `text` and the two spec lists — the inputs `RunAttributedText.build`
* styles. `Attribute` and `Decoration` are data classes, so
* - `text` and the three spec lists — the inputs `RunAttributedText.build`
* styles. `Attribute`, `Decoration` and `Embed` are data classes, so
* `List.equals` is a deep value comparison and no equals/hashCode had to
* be added to the `Spec` wrappers (the key holds the lists, not the
* wrappers).
Expand Down Expand Up @@ -98,11 +98,16 @@ internal object RunLayoutCache {
* `localeTag` is in the key because the paint's `textLocale` (set in
* `configurePaint` from `Locale.getDefault()`) moves line-break and
* metric decisions for CJK scripts: a locale change with an unchanged
* key would serve a layout measured under the previous locale. */
* key would serve a layout measured under the previous locale.
* `embeds` is in the key because `build` bakes each reservation into a
* `RunEmbedSpan`: an embed whose declared size changed under unchanged
* text would otherwise be served the previous size's spannable for as
* long as the LRU kept it. */
internal data class Key(
val text: String,
val attributes: List<RunAttributedText.Attribute>,
val decorations: List<RunDecorations.Decoration>,
val embeds: List<RunEmbeds.Embed>,
val density: Float,
val scaledDensity: Float,
val localeTag: String,
Expand Down Expand Up @@ -225,12 +230,14 @@ internal object RunLayoutCache {
text: String,
attributes: RunAttributedText.Spec,
decorations: RunDecorations.Spec,
embeds: RunEmbeds.Spec,
): Key {
val metrics = DisplayMetricsHolder.getWindowDisplayMetrics()
return Key(
text,
attributes.attributes,
decorations.decorations,
embeds.embeds,
metrics.density,
metrics.scaledDensity,
Locale.getDefault().toLanguageTag(),
Expand Down Expand Up @@ -293,13 +300,15 @@ internal object RunLayoutCache {
key.text,
RunAttributedText.Spec(key.attributes),
RunDecorations.Spec(key.decorations),
RunEmbeds.Spec(key.embeds),
)
}
synchronized(spannables) { spannables[key] }?.let { return it }
val built = RunAttributedText.build(
key.text,
RunAttributedText.Spec(key.attributes),
RunDecorations.Spec(key.decorations),
RunEmbeds.Spec(key.embeds),
)
// Built under inputs that no longer match the key (metrics or locale
// moved mid-build): hand it back uncached rather than poison the map.
Expand Down
Loading
Loading