diff --git a/README.md b/README.md
index e4ec456..ec5a53c 100644
--- a/README.md
+++ b/README.md
@@ -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) => ,
+ }
+ : undefined;
+
+
+```
+
+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
@@ -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 |
| --- | --- |
@@ -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. |
diff --git a/android/src/main/java/com/selectablemarkdown/EmbedLayoutEvent.kt b/android/src/main/java/com/selectablemarkdown/EmbedLayoutEvent.kt
new file mode 100644
index 0000000..000185a
--- /dev/null
+++ b/android/src/main/java/com/selectablemarkdown/EmbedLayoutEvent.kt
@@ -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(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"
+ }
+}
diff --git a/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt b/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt
index c0560e6..96bbeb5 100644
--- a/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt
+++ b/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt
@@ -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
@@ -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
}
}
diff --git a/android/src/main/java/com/selectablemarkdown/RunEmbeds.kt b/android/src/main/java/com/selectablemarkdown/RunEmbeds.kt
new file mode 100644
index 0000000..edfa5c0
--- /dev/null
+++ b/android/src/main/java/com/selectablemarkdown/RunEmbeds.kt
@@ -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