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) { + companion object { + val EMPTY = Spec(emptyList()) + } + } + + fun parse(source: ReadableArray?): Spec { + if (source == null || source.size() == 0) return Spec.EMPTY + val parsed = ArrayList(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. + } +} diff --git a/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt b/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt index f142cd4..5b50651 100644 --- a/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt +++ b/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt @@ -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). @@ -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, val decorations: List, + val embeds: List, val density: Float, val scaledDensity: Float, val localeTag: String, @@ -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(), @@ -293,6 +300,7 @@ internal object RunLayoutCache { key.text, RunAttributedText.Spec(key.attributes), RunDecorations.Spec(key.decorations), + RunEmbeds.Spec(key.embeds), ) } synchronized(spannables) { spannables[key] }?.let { return it } @@ -300,6 +308,7 @@ internal object RunLayoutCache { 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. diff --git a/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt b/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt index 088048c..f5c52c1 100644 --- a/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt +++ b/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt @@ -238,6 +238,7 @@ internal object RunTextMeasure { text: String, spec: RunAttributedText.Spec, decorations: RunDecorations.Spec, + embeds: RunEmbeds.Spec, width: Float, widthMode: YogaMeasureMode, height: Float, @@ -256,7 +257,7 @@ internal object RunTextMeasure { // on every prop batch — arrive here with identical inputs and // identical constraints, and the second lookup turns that whole case // into a map get. - val key = RunLayoutCache.key(text, spec, decorations) + val key = RunLayoutCache.key(text, spec, decorations, embeds) RunLayoutCache.measurement(key, width, widthMode, height, heightMode)?.let { return it } val paint = checkNotNull(scratchPaint.get()) diff --git a/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt b/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt index cec0ab4..cb01fcd 100644 --- a/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt +++ b/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt @@ -116,7 +116,14 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { private var pendingText: String = "" private var pendingAttributes: RunAttributedText.Spec = RunAttributedText.Spec.EMPTY private var pendingDecorations: RunDecorations.Spec = RunDecorations.Spec.EMPTY + private var pendingEmbeds: RunEmbeds.Spec = RunEmbeds.Spec.EMPTY private var textDirty = false + + /** Last rect reported per embedId, in dp — the dedupe that keeps + * streaming appends past a settled embed from re-announcing it on every + * snapshot. Values only ever compared against the next report. */ + private val lastEmbedRects = HashMap() + /** Reused per draw pass; a run redraws on every scroll frame under a * selection, and allocating in onDraw is the canonical Android jank. */ private val decorationPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) @@ -296,6 +303,22 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { invalidate() } + /** + * Guarded by value like `setAttributes` (same Fabric re-delivery + * reason), and layout-affecting like `setDecorations`: each reservation + * is a ReplacementSpan in the styled string, so a changed list must + * rebuild the text. The rect dedupe map is cleared on a real change + * because embedIds are per-list ordinals — id 0 of the new list is not + * id 0 of the old one, and a stale "already reported" entry would + * swallow the first report the new list deserves. + */ + fun setEmbeds(spec: RunEmbeds.Spec) { + if (spec.embeds == pendingEmbeds.embeds) return + pendingEmbeds = spec + lastEmbedRects.clear() + textDirty = true + } + /** Applied once per prop batch, from the view manager. */ fun commitProps() { if (!textDirty) return @@ -327,12 +350,20 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { // value from the same two props (RunTextMeasure.baseTextSizeSp). RunTextMeasure.updateTextViewBaseSize(textView, pendingText, pendingAttributes) textView.text = RunLayoutCache.styledText( - RunLayoutCache.key(pendingText, pendingAttributes, pendingDecorations) + RunLayoutCache.key(pendingText, pendingAttributes, pendingDecorations, pendingEmbeds) ) // The chrome is positioned off the text layout, so this ViewGroup's // own display list is stale the moment the text moves — and a child // invalidation alone does not rebuild the parent's. invalidate() + // Embed rects are read off the TextView's Layout, which does not + // exist until the layout pass the setText above requested. onLayout + // is the primary report point; the post is the belt for commits the + // framework decides not to relayout (the dedupe map makes a double + // report free). + if (pendingEmbeds.embeds.isNotEmpty()) { + post { reportEmbedRects() } + } } fun setSelectable(value: Boolean) { @@ -473,6 +504,98 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { ) } + // ---- Embed rect reporting ------------------------------------------------ + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + // The primary report point: by the time a FrameLayout's own onLayout + // runs, the child TextView has been measured and laid out, so its + // internal Layout — the geometry source below — exists and is + // current. commitProps posts a second call for commits the framework + // decides need no relayout; the dedupe map makes the overlap free. + reportEmbedRects() + } + + /** + * Reports where each embed's reserved space landed, in this host's + * coordinate space, dp, through `onEmbedLayout`. Re-fired only when a + * rect actually moved (> 0.5dp on any edge) — streaming appends past a + * settled embed recommit the view constantly, and a settled embed's + * geometry never moves (a settled run only ever grows at its end), so + * without the dedupe JS would be re-told the same rect every snapshot. + * + * GEOMETRY IS ANCHORED ON THE LINE, NOT THE BASELINE. The reservation is + * ascent-shaped in `RunEmbedSpan`, but the final line extents belong to + * the embed-height `RunLineHeightSpan` JS sends over the same character, + * and its surplus branch re-centres the extra room around the baseline — + * so `getLineBaseline - height` can point above the line's top. The top + * of the placeholder's line IS the top of the reserved band, whatever + * the baseline did. + * + * The horizontal edge takes the smaller of the two `getPrimaryHorizontal` + * answers: in an RTL paragraph the placeholder's leading edge is its + * RIGHT edge, and the overlay is positioned by physical left. + */ + private fun reportEmbedRects() { + val embeds = pendingEmbeds.embeds + if (embeds.isEmpty()) return + val layout = textView.layout ?: return + val text = textView.text ?: return + val length = text.length + + val reactContext = context as ReactContext + val dispatcher = + UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return + val surfaceId = UIManagerHelper.getSurfaceId(this) + + val textLeft = (textView.left + textView.totalPaddingLeft - textView.scrollX).toFloat() + val textTop = (textView.top + textView.totalPaddingTop - textView.scrollY).toFloat() + + for (embed in embeds) { + // The same guards `RunEmbeds.applySpans` applied to the string: + // an entry that reserved nothing must report nothing. + if (embed.start >= length || embed.end > length) continue + if (text[embed.start] != RunEmbeds.PLACEHOLDER) continue + + val line = layout.getLineForOffset(embed.start) + val xPx = textLeft + minOf( + layout.getPrimaryHorizontal(embed.start), + layout.getPrimaryHorizontal(embed.end), + ) + val yPx = textTop + layout.getLineTop(line) + + val x = PixelUtil.toDIPFromPixel(xPx) + val y = PixelUtil.toDIPFromPixel(yPx) + // Size is the declared reservation, echoed back in the unit it + // arrived in rather than round-tripped through pixels: the span + // reserved exactly this box, and JS positions an overlay it + // already knows the size of. + val rect = android.graphics.RectF(x, y, x + embed.widthDp, y + embed.heightDp) + val last = lastEmbedRects[embed.embedId] + if (last != null && + kotlin.math.abs(last.left - rect.left) <= 0.5f && + kotlin.math.abs(last.top - rect.top) <= 0.5f && + kotlin.math.abs(last.right - rect.right) <= 0.5f && + kotlin.math.abs(last.bottom - rect.bottom) <= 0.5f + ) { + continue + } + lastEmbedRects[embed.embedId] = rect + + dispatcher.dispatchEvent( + EmbedLayoutEvent( + surfaceId, + id, + embed.embedId, + x, + y, + embed.widthDp, + embed.heightDp, + ) + ) + } + } + // ---- Decorations --------------------------------------------------------- /** @@ -667,6 +790,12 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { pendingText = "" pendingAttributes = RunAttributedText.Spec.EMPTY pendingDecorations = RunDecorations.Spec.EMPTY + // Embeds and their report ledger go together: a recycled host that + // kept either could report the PREVIOUS run's rects against the next + // run's embedIds — the embed cousin of the stale-selection failure + // this method exists to prevent. + pendingEmbeds = RunEmbeds.Spec.EMPTY + lastEmbedRects.clear() textDirty = false selectionActions = listOf(ACTION_COPY_TEXT, ACTION_COPY_MARKDOWN) // A fresh host has no tappable ranges; a recycled one keeping the diff --git a/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt b/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt index 1204bdd..0644d14 100644 --- a/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt +++ b/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt @@ -142,6 +142,15 @@ class SelectableRunHostViewManager : view.setPressables(SelectableRunHostView.parsePressables(pressables)) } + @ReactProp(name = "embeds") + override fun setEmbeds(view: SelectableRunHostView, embeds: ReadableArray?) { + // Same on-arrival discipline as `decorations`, and folded into the + // same one styled string in onAfterUpdateTransaction — a reservation + // is a ReplacementSpan in the spannable. A null prop (reset) parses + // to the empty spec: reserve nothing, report nothing. + view.setEmbeds(RunEmbeds.parse(embeds)) + } + @ReactProp(name = "selectionActions") override fun setSelectionActions(view: SelectableRunHostView, actions: ReadableArray?) { if (actions == null) { @@ -221,8 +230,13 @@ class SelectableRunHostViewManager : } else { RunDecorations.Spec.EMPTY } + val embeds = if (props != null && props.hasKey("embeds")) { + RunEmbeds.parse(props.getArray("embeds")) + } else { + RunEmbeds.Spec.EMPTY + } val measured = - RunTextMeasure.measure(text, attributes, decorations, width, widthMode, height, heightMode) + RunTextMeasure.measure(text, attributes, decorations, embeds, width, widthMode, height, heightMode) return YogaMeasureOutput.make( PixelUtil.toDIPFromPixel(YogaMeasureOutput.getWidth(measured)), PixelUtil.toDIPFromPixel(YogaMeasureOutput.getHeight(measured)), @@ -278,6 +292,8 @@ class SelectableRunHostViewManager : mapOf("registrationName" to "onSelectionAction") constants[InlinePressEvent.EVENT_NAME] = mapOf("registrationName" to "onInlinePress") + constants[EmbedLayoutEvent.EVENT_NAME] = + mapOf("registrationName" to "onEmbedLayout") return constants } diff --git a/android/src/main/jni/RNSMRunTextMeasurer.cpp b/android/src/main/jni/RNSMRunTextMeasurer.cpp index 1fd2d73..0e224d6 100644 --- a/android/src/main/jni/RNSMRunTextMeasurer.cpp +++ b/android/src/main/jni/RNSMRunTextMeasurer.cpp @@ -94,11 +94,18 @@ Size RNSMRunTextMeasurer::measure( * The nine-argument overload (FabricUIManager.java:508-529), which forwards * to the ten-argument one with a null `attachmentsPositions`. That array is * RN's protocol for its OWN AttributedString attachments, positioned by the - * measurer so the shadow tree can lay child views into text. A run carries - * no view-shaped content — everything it renders is text with spans — so - * there is nothing to position through that channel. `measure` is private - * on the Java side — JNI does not care, and every in-tree measurements - * manager binds it the same way. + * measurer so the shadow tree can lay child views into text. Runs DO carry + * view-shaped content now — the `embeds` prop reserves space for overlaid + * consumer views — but their rects deliberately do not travel this channel: + * embed geometry is reported by the mounted view after layout + * (`SelectableRunHostView.reportEmbedRects` -> `onEmbedLayout`), because + * the overlay is a JS-positioned sibling, not a shadow-tree child, so the + * measurer has nobody to hand positions to. The reservations themselves + * still measure correctly through this call: `embeds` rides the raw props + * forwarded below, and the Kotlin side folds it into the measured + * spannable as ReplacementSpans. `measure` is private on the Java side — + * JNI does not care, and every in-tree measurements manager binds it the + * same way. * * `static` so the method id is resolved once per process rather than once * per measure. Yoga calls this several times per layout pass. diff --git a/conformance/selection/projection-oracle.test.ts b/conformance/selection/projection-oracle.test.ts index 0deaa5c..4a43a3f 100644 --- a/conformance/selection/projection-oracle.test.ts +++ b/conformance/selection/projection-oracle.test.ts @@ -45,7 +45,7 @@ import { buildCopyPayload } from '../../src/selection/copy'; import { mapSelectionToSource, projectRun } from '../../src/selection/mapSelection'; import type { ProjectedRun } from '../../src/selection/mapSelection'; import { segmentRuns } from '../../src/selection/runs'; -import type { RunSegment } from '../../src/selection/runs'; +import type { EmbedLookup, RunSegment } from '../../src/selection/runs'; const FIXTURE_DIR = path.resolve(__dirname, '..', 'fixtures'); const SPEC_PATH = path.resolve(__dirname, '..', 'vendor', 'spec.json'); @@ -273,3 +273,138 @@ describeNative('projection oracle: no soft break projects a newline', () => { }, ); }); + +/** + * THE EMBED SWEEP: the same three invariants the main oracle holds — piece + * tiling, in-bounds mapping, copy-slice identity — re-asserted over the + * corpus reprojected with a synthetic embed claim on every link. An embed + * replaces a whole subtree's projection with one U+FFFC placeholder mapped + * non-linearly to the node's span, which is exactly the kind of change the + * hand-built fixtures cannot stress: the corpus holds links inside emphasis, + * headings, list items, tables, blockquotes — the nestings nobody thinks to + * write down, and the ones where a mis-tiled placeholder piece or a hull that + * leaks past the node's span would actually hide. + * + * Links are the claim target because they are the construct the feature + * exists for (citation cards) and the corpus is full of them in every + * position. The oracle's own three tests keep running with NO claim, so this + * block is purely additive. + */ +describeNative.each([ + ['llmChat', presets.llmChat], + ['everything', presets.everything], +] as [string, EngineOptions][])('projection oracle: embeds (%s)', (_name, baseOptions) => { + // `blockedLinks: 'node'` is the configuration the feature exists for (the + // README's citation story), and it is also what makes this sweep dense: + // most corpus links carry relative or exotic hrefs the default URL policy + // strips at parse time — under the plain presets only a handful of link + // NODES exist to claim. Keeping them as blocked nodes turns nearly every + // corpus link into an embed. + const options: EngineOptions = { + ...baseOptions, + urlPolicy: { ...baseOptions.urlPolicy, blockedLinks: 'node' }, + }; + const claimLinks: EmbedLookup = (node) => + node.kind === 'link' ? { width: 160, height: 48, text: '[ref]' } : undefined; + + function embedRuns( + doc: ParsedDocument, + ): { run: RunSegment; projected: ProjectedRun }[] { + return segmentRuns(doc, { embed: claimLinks }) + .filter((run) => !run.standalone) + .map((run) => ({ run, projected: projectRun(run, doc, { embed: claimLinks }) })); + } + + it('embed-bearing runs keep tiling, and every embed owns one placeholder piece', () => { + let embeds = 0; + for (const { label, source } of corpus) { + const doc = parseDocument(source, options); + for (const { projected } of embedRuns(doc)) { + let cursor = 0; + for (const piece of projected.pieces) { + if (piece.textStart !== cursor || piece.textEnd <= piece.textStart) { + throw new Error( + `${label}: embed piece table does not tile — expected the next piece ` + + `to start at ${cursor}, got ${JSON.stringify(piece)}`, + ); + } + cursor = piece.textEnd; + } + if (cursor !== projected.text.length) { + throw new Error( + `${label}: embed piece table covers ${cursor} of ${projected.text.length}`, + ); + } + for (const embed of projected.embeds ?? []) { + embeds++; + if (embed.end !== embed.start + 1) { + throw new Error(`${label}: embed range is not one character`); + } + if (projected.text[embed.start] !== '') { + throw new Error( + `${label}: embed placeholder at ${embed.start} is ` + + `${JSON.stringify(projected.text[embed.start])}, not U+FFFC`, + ); + } + const piece = projected.pieces.find( + (candidate) => candidate.textStart === embed.start, + ); + if ( + !piece || + piece.textEnd !== embed.end || + piece.source === null || + piece.source.start !== embed.node.span.start || + piece.source.end > doc.source.length + ) { + throw new Error( + `${label}: embed at ${embed.start} does not own one piece over its ` + + `node span — got ${JSON.stringify(piece)}`, + ); + } + const mark = projected.marks.find( + (candidate) => + candidate.kind === 'embed' && candidate.start === embed.start, + ); + if (!mark || mark.embedId !== embed.embedId) { + throw new Error(`${label}: embed at ${embed.start} has no matching mark`); + } + } + } + } + // Vacuity guard: the corpus really does hold links in prose runs. + expect(embeds).toBeGreaterThan(100); + }); + + it('selections over embed-bearing runs map in bounds and copy the exact slice', () => { + let mapped = 0; + for (const { label, source } of corpus) { + const doc = parseDocument(source, options); + for (const { projected } of embedRuns(doc)) { + if (!projected.embeds) continue; + const offsets = selectionOffsets(projected.text.length); + for (const start of offsets) { + for (const end of offsets) { + if (end <= start) continue; + const span = mapSelectionToSource(projected, { start, end }); + if (span === null) continue; + mapped++; + if (span.start < 0 || span.end > doc.source.length || span.start >= span.end) { + throw new Error( + `${label}: embed selection [${start},${end}) mapped out of bounds: ` + + JSON.stringify(span), + ); + } + const payload = buildCopyPayload(doc, span, { options }); + if (payload.markdown !== doc.source.slice(span.start, span.end)) { + throw new Error( + `${label}: embed copy payload is not the source slice for ` + + JSON.stringify(span), + ); + } + } + } + } + } + expect(mapped).toBeGreaterThan(5_000); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67c3b3a..f4e7b21 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,6 +96,7 @@ src/ runAttributes.ts marks + theme -> RunTextAttribute[] for the host runDecorations.ts marks + theme -> block chrome the host paints runPressables.ts link marks -> tappable ranges the host hit-tests + runEmbeds.ts embed entries -> RunEmbed[] the host reserves space from selectionActions.ts handleSelectionAction(): menu event -> onSelectionCopy payload theme.ts grouped tokens, mergeTheme, defaultTheme/defaultDarkTheme renderers.tsx default per-kind renderers + override types diff --git a/docs/FABRIC-PLAN.md b/docs/FABRIC-PLAN.md index 3591714..d9f7f20 100644 --- a/docs/FABRIC-PLAN.md +++ b/docs/FABRIC-PLAN.md @@ -4,9 +4,11 @@ A design retrospective, not a live plan. It records how the Fabric (new-architecture) port was built and why, written against react-native 0.75.4. The port shipped in full, including the goals §9 proposed deferring. -Two things it treats as live were removed in 0.10.0, when the peer range moved -to `react-native >= 0.82`: the old-architecture (Paper) path and the embed -subsystem. Read any "both architectures" or `embeds` discussion as history. +One thing it treats as live was removed in 0.10.0, when the peer range moved +to `react-native >= 0.82`: the old-architecture (Paper) path. Read any "both +architectures" discussion as history. The embed subsystem was removed in the +same release and restored, Fabric-only, in 0.11.0, so its `embeds` +discussion is current again. The `file.ext:NN-MM` citations are stale. Source comments cite this document by section number, which is why it stays and why the numbering never changes. diff --git a/docs/SELECTION.md b/docs/SELECTION.md index 0dbca30..604aa55 100644 --- a/docs/SELECTION.md +++ b/docs/SELECTION.md @@ -33,8 +33,11 @@ into it; JS maps back to source through the run's piece table. A run is a maximal sequence of adjacent flowing blocks merged into one selectable unit. Every other block is `standalone: true` and gets its own -selection scope. There is no third mode: a custom card is a standalone -block, and the sweep stops at it. +selection scope. A third mode sits between them: an **embedded** node (the +`embed` prop) flows through its run as one U+FFFC placeholder with a +consumer-rendered view overlaid on space the host reserves; see +"Event: `onEmbedLayout`" below. An embed claim is consulted before +everything that follows and always means *flowing*. `classifyBlock` (`src/selection/runs.ts`) decides, in order: @@ -72,6 +75,7 @@ contract is normative and lives in `src/selection/mapSelection.ts`: | Soft break (a wrapped source line) | `' '` (one space) | | Thematic break | empty (chrome only) | | Blockquote | no quote glyph, children only | +| Embedded node (an `embed` claim) | `` (U+FFFC, one character, mapped to the node's whole span) | Separators are fixed constants. Bullet and task glyphs are the defaults of `theme.glyphs`; `projectRun` accepts `{ glyphs }` overrides and the view @@ -127,6 +131,16 @@ integration, view recycling and measure/draw agreement are reviewed, not executed: this repository has no example app, simulator or Android SDK. `FABRIC-PLAN.md` §8 and §9 list what is proven and the cut line. +Embeds inherit that split. The mapping side is executed: projection, piece +atomicity, copy payloads, and the corpus-scale embed sweep in the projection +oracle. Everything rendered is reviewed only: the reservation inside a +line-height-pinned line, selection over the placeholder, `onEmbedLayout` +delivery and its dedupe, recycling of embed state, overlay z-order against +the selection highlight (the highlight paints in the text view, the card +above it), gesture arbitration on the card, VoiceOver/TalkBack on U+FFFC, and +RTL x-coordinates. That is the first list to work through when an example +app exists. + Custom menu items need iOS 16 (`textView(_:editMenuForTextIn:suggestedActions:)`). From the podspec floor (13.4) to 15.x, `selectionActions` is accepted, no custom item renders, `onSelectionAction` never fires, and the system Copy @@ -140,6 +154,7 @@ works. | `attributes` | `object[]` | Styled ranges `{ start, end }` plus any of `fontFamily`, `fontSize`, `lineHeight`, `fontWeight`, `fontStyle`, `textDecorationLine`, `color`, `backgroundColor`. Sparse, ordered outermost-first so the innermost wins. Colours are `processColor` integers. | | `decorations` | `object[]` | Block chrome `{ start, end, kind }` plus styling, from `resolveRunDecorations`. Geometric kinds, never moves a character. Older binaries ignore it. | | `pressables` | `object[]` | Tappable ranges `{ start, end, pressableId }`, non-overlapping. `[]` when no `onInlinePress` listener exists. Older binaries ignore it. | +| `embeds` | `object[]` | Embedded ranges `{ start, end, embedId, width, height }`, each the single U+FFFC placeholder an `embed` claim projected (`end === start + 1`). The host reserves `width` × `height` points there — an `NSTextAttachment` on iOS, a `ReplacementSpan` on Android, both applied inside the shared string builder so measurement and drawing agree — and reports the rect through `onEmbedLayout`. The height also rides `attributes` as a `lineHeight` over the placeholder, never smaller than the covering attributes already give the line. Host guards: positive size, 1-unit range, the character really is U+FFFC; a stale entry reserves nothing. Layout-affecting, so sent whenever embeds exist, not gated on a listener. Older binaries ignore it: the placeholder is an invisible one-character gap (transparent colour via `attributes`), no overlay mounts, mapping stays exact. | | `selectable` | `boolean` | Whether platform selection UI is enabled. Driven by the tail policy. | | `selectionActions` | `string[]` | Ordered identifiers (`'copy-text'`, `'copy-markdown'`) for custom menu items. Default both. `[]` when no `onSelectionAction` listener exists. Unknown identifiers are ignored. | | `testID` | `string?` | Standard RN test handle. | @@ -237,6 +252,52 @@ Host guarantees, both platforms: Version skew degrades both ways: an older binary never emits, an older JS sends no `pressables`. +## Event: `onEmbedLayout` + +Fired per embed after layout, with the reserved rect in the host view's +coordinate space, so JS can position the consumer's view over it. One event +per embed with a scalar payload: an array-of-objects payload is not +verifiably supported by codegen, and per-embed events avoid cross-id +coalescing (`canCoalesce()` is `false`, like the other events; the dedupe +lives in the host). + +```ts +interface EmbedLayoutEvent { + embedId: number; // JS's identifier for the embed, echoed verbatim + x: number; // reserved rect, host-view points + y: number; + width: number; + height: number; +} +``` + +Host guarantees, both platforms: + +1. Rects come from the layout the host draws with: iOS + `glyphRange(forCharacterRange:)` + `boundingRect(forGlyphRange:in:)` on + the shared TextKit stack; Android the `TextView`'s own `Layout`, anchored + on the line top rather than the baseline. +2. A rect is re-emitted only when it moved (> 0.5pt in any component), per + `embedId`. Streaming appends past a settled embed re-announce nothing. +3. The range is one unit and the character is U+FFFC, verified before + reporting exactly as before reserving. +4. `embedId` is echoed verbatim. JS bounds-checks it against the list it + sent and drops rects reported against a previous projection (ids are + per-projection ordinals). + +The host never learns what an embed is. JS keeps the node, the render +function and the copy text; the host gets ranges, sizes and ids and hands +back geometry — the `pressables` division of knowledge. + +The overlay is a **sibling** of the host, absolutely positioned by +`SelectableMarkdown` inside a relatively positioned wrapper, never a child: +the native component is a leaf on both platforms. So a card appears one +frame after its run first lays out, into space that was reserved natively +from the first frame — no reflow. While the run is the unsettled streaming +tail no overlay mounts at all; the reservation still does, so the card +appears without a reflow when the run settles. A long-press on the card +starts no selection; the sweep starts on the prose around it. + ## `handleSelectionAction` `src/view/selectionActions.ts` exports the unit-tested core the view routes @@ -341,6 +402,13 @@ event emitter (the host must not cache it). Android does the same in `ViewManager.prepareToRecycleView`, on top of the detach-time discipline (action mode finished, custom callback uninstalled). +Embed state joins that guarantee: both hosts clear their per-embed +rect-dedupe ledgers on recycle (and whenever `embeds` changes), because a +host that considered the previous run's rects already reported would never +re-report for the new one. JS is defensive independently: rects belong to +the projection they were reported against, and an unknown `embedId` is +dropped. + Recycling is only reachable in a running app, so it is reviewed, not run. ## Tail policy (streaming) @@ -363,7 +431,12 @@ as an empty-selection change, so it does not recurse. Code blocks, tables and rules flow through prose runs, so one gesture selects across them. A block is a separate scope only when a nested image or -spoiler, or a `classifyBlock` claim, makes it standalone. +spoiler, or a `classifyBlock` claim, makes it standalone. An `embed` claim +runs the other way: it keeps a custom-rendered view *inside* the run (one +placeholder character, reserved space, overlaid view), so a citation card no +longer costs the sweep. Selecting across an embed is atomic — the +placeholder is one character whose piece maps to the node's whole source +span, so any sweep that covers the card copies the card's exact markdown. ### What a standalone block does and does not get @@ -374,17 +447,19 @@ whose renderers set `selectable` themselves. Inside a run that prop is inert: a nested `` is `RCTVirtualText`, whose view config does not list `selectable`, so it cannot override the Android tail policy. -| | Prose run | Standalone prose block | Code block (claimed) | Table (claimed) | -| --- | --- | --- | --- | --- | -| Selectable | yes | yes | yes | yes, per cell | -| Own selection scope | yes | yes | yes | yes, per cell | -| System Copy | yes | yes | yes | yes | -| `selectionActions` / `onSelectionCopy` | yes | no | no | no | +| | Prose run | Embedded node | Standalone prose block | Code block (claimed) | Table (claimed) | +| --- | --- | --- | --- | --- | --- | +| Selectable | yes | yes, atomically in its run | yes | yes | yes, per cell | +| Own selection scope | yes | no, flows through its run | yes | yes | yes, per cell | +| System Copy | yes | yes (yields U+FFFC for the card) | yes | yes | yes | +| `selectionActions` / `onSelectionCopy` | yes | yes, via its run | no | no | no | "Copy Markdown" is a prose-run feature, which covers code blocks, tables and -rules by default. Blocks that end up standalone copy their displayed text -through the platform Copy. Routing standalone blocks through a host of their -own is roadmap work. +rules by default, and embedded views with them. Blocks that end up standalone +copy their displayed text through the platform Copy. Routing standalone +blocks through a host of their own is roadmap work. An `embed` claim is the +middle way: the card keeps its own touches inside its own bounds while the +run around and under it keeps selection. ## Sizing diff --git a/package-lock.json b/package-lock.json index 18154bf..6f0b05d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-native-selectable-markdown", - "version": "0.8.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-native-selectable-markdown", - "version": "0.8.0", + "version": "0.11.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.12", diff --git a/package.json b/package.json index 661fe99..ab92656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-selectable-markdown", - "version": "0.10.0", + "version": "0.11.0", "description": "Streaming-first CommonMark + GFM markdown renderer for React Native with document-grade text selection: every rendered character maps back to an exact UTF-16 range of the source.", "license": "MIT", "private": false, diff --git a/platform/fabric/RNSMRunHostShadowNode.h b/platform/fabric/RNSMRunHostShadowNode.h index a9f12d0..4cdd773 100644 --- a/platform/fabric/RNSMRunHostShadowNode.h +++ b/platform/fabric/RNSMRunHostShadowNode.h @@ -178,11 +178,16 @@ class RNSMRunHostShadowNode final : public ConcreteViewShadowNode< * `ParagraphShadowNode::layout` re-measures at the final size, but only * because it has to position attachments (inline views inside text), and * it guards that second measure behind the `preventDoubleTextMeasure` - * feature flag (ParagraphShadowNode.cpp:183-209). A run has NO attachments - * — everything it renders is text with styling — so there is nothing for - * layout() to position, the second measure would be pure cost on every - * commit, and we do not acquire a dependency on `react_featureflags` to - * decide about it. + * feature flag (ParagraphShadowNode.cpp:183-209). We DO have attachments + * now — an embed reserves its declared rect at a U+FFFC placeholder + * (RNSMEmbedAttachment on iOS, a ReplacementSpan on Android) — but nothing + * about them is positioned HERE: the reservation is part of the measured + * string itself, and where it landed is reported by the host VIEW after + * mount through the `onEmbedLayout` event, which is what JS positions the + * overlay from (docs/SELECTION.md, "Event: onEmbedLayout"). So there is + * still nothing for layout() to position, the second measure would still + * be pure cost on every commit, and we still do not acquire a dependency + * on `react_featureflags` to decide about it. */ void layout(LayoutContext layoutContext) override; diff --git a/platform/ios/RNSMAttributedText+Props.h b/platform/ios/RNSMAttributedText+Props.h index b59fc3b..e522387 100644 --- a/platform/ios/RNSMAttributedText+Props.h +++ b/platform/ios/RNSMAttributedText+Props.h @@ -66,6 +66,16 @@ NS_ASSUME_NONNULL_BEGIN + (NSArray *)decorationsWithProps: (const facebook::react::SelectableRunHostProps &)props; +/** + * The `embeds` prop decoded into the same dictionary wire the string builder + * consumes, one decoder for both of its consumers: the builder above (which + * attaches the reservation to each placeholder) and the Fabric component + * view, which hands the array to the Swift host for rect reporting through + * `onEmbedLayout`. + */ ++ (NSArray *)embedsWithProps: + (const facebook::react::SelectableRunHostProps &)props; + @end NS_ASSUME_NONNULL_END diff --git a/platform/ios/RNSMAttributedText.h b/platform/ios/RNSMAttributedText.h index e5e5b92..1713c7c 100644 --- a/platform/ios/RNSMAttributedText.h +++ b/platform/ios/RNSMAttributedText.h @@ -34,7 +34,7 @@ NS_ASSUME_NONNULL_BEGIN /** * Builds the rendered string from the projected run text plus JS's styled - * ranges and block decorations. + * ranges, block decorations and embed reservations. * * THE ONE BUILDER, AND THE ONLY ENTRY POINT. There used to be two-, three- * and four-argument overloads that filled the trailing arguments with nil @@ -70,13 +70,36 @@ NS_ASSUME_NONNULL_BEGIN * All of it moves where glyphs sit and never which glyphs exist, so the * UTF-16 offset contract is untouched. * - * Malformed entries in either array are skipped rather than + * `embeds` is an array of dictionaries with `start`/`end` (always a 1-unit + * range over a U+FFFC placeholder the projection emitted), `embedId`, + * `width`, `height`. Each valid entry attaches an invisible NSTextAttachment + * sized `width` x `height` to the placeholder character, which is how the + * reservation reaches layout — and because this builder is shared, how it + * reaches measurement identically. + * + * ATTRIBUTE-ONLY, ZERO INSERTION. The attachment is added with + * `addAttribute:` to a character JS already put in the text; nothing here + * ever calls `attributedStringWithAttachment:`, which would insert a second + * U+FFFC and break the offset contract at the top of this header. An entry + * whose range is not exactly one U+FFFC character — prop skew, a stale + * offset — degrades to "no reservation", never to attaching over a real + * character. + * + * The reservation's HEIGHT is honoured only because JS also sends a + * `lineHeight` attribute equal to `height` over the same character: the + * line-height pass pins min = max, so without it the attachment would be + * clamped into the body leading (see the lineHeight comment in the .mm). + * The two travel in one prop batch, derived from one `EmbedContent`, so + * they cannot disagree. + * + * Malformed entries in any array are skipped rather than * trapped: a range from a newer JS bundle than this binary understands must * degrade to unstyled text, never to a crash in a render pass. */ + (NSAttributedString *)attributedStringWithText:(NSString *)text attributes:(nullable NSArray *)attributes - decorations:(nullable NSArray *)decorations; + decorations:(nullable NSArray *)decorations + embeds:(nullable NSArray *)embeds; @end diff --git a/platform/ios/RNSMAttributedText.mm b/platform/ios/RNSMAttributedText.mm index 8a8e245..4e3646a 100644 --- a/platform/ios/RNSMAttributedText.mm +++ b/platform/ios/RNSMAttributedText.mm @@ -391,11 +391,87 @@ static void RNSMApplyRowPadding( } } +/* + * The space an embed reserves at its U+FFFC placeholder. Draws nothing — the + * consumer's React view is overlaid at the rect the host reports through + * onEmbedLayout — so the only things this class contributes are its bounds + * (the reservation) and its equality. + */ +@interface RNSMEmbedAttachment : NSTextAttachment + +@property (nonatomic, readonly) NSInteger embedId; + +- (instancetype)initWithEmbedId:(NSInteger)embedId + bounds:(CGRect)bounds NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithData:(nullable NSData *)contentData + ofType:(nullable NSString *)uti NS_UNAVAILABLE; +- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +@end + +@implementation RNSMEmbedAttachment + +- (instancetype)initWithEmbedId:(NSInteger)embedId bounds:(CGRect)bounds +{ + if (self = [super initWithData:nil ofType:nil]) { + _embedId = embedId; + self.bounds = bounds; + } + return self; +} + +/* + * Nothing to draw: no image, whatever the bounds. Without the override a + * contentless NSTextAttachment can render its "missing attachment" glyph, + * and the reservation must read as empty space under the overlaid card. + */ +- (nullable UIImage *)imageForBounds:(CGRect)imageBounds + textContainer:(nullable NSTextContainer *)textContainer + characterIndex:(NSUInteger)charIndex +{ + return nil; +} + +/* + * VALUE EQUALITY, AND IT IS LOAD-BEARING. NSTextAttachment inherits pointer + * `isEqual:`, and the builder allocates a fresh attachment on every rebuild — + * which under streaming is every snapshot. The host's append fast path + * (SelectableRunHostView.apply) compares the new string's prefix against the + * storage with `isEqual(to:)`, which compares attribute values via `isEqual:` + * — so a pointer-identity attachment would fail that compare on every append + * and silently demote each one to a full swap: full relayout of the settled + * prefix plus a save/clamp/restore of the selection, the exact costs the fast + * path exists to remove, with rendering that stays perfectly correct. Two + * attachments are the same reservation iff they reserve the same rect for the + * same embed. + */ +- (BOOL)isEqual:(id)object +{ + if (self == object) { + return YES; + } + if (![object isKindOfClass:[RNSMEmbedAttachment class]]) { + return NO; + } + RNSMEmbedAttachment *other = (RNSMEmbedAttachment *)object; + return _embedId == other->_embedId && CGRectEqualToRect(self.bounds, other.bounds); +} + +- (NSUInteger)hash +{ + CGRect bounds = self.bounds; + return (NSUInteger)_embedId ^ ((NSUInteger)bounds.size.width << 8) ^ + ((NSUInteger)bounds.size.height << 16); +} + +@end + @implementation RNSMAttributedText + (NSAttributedString *)attributedStringWithText:(NSString *)text attributes:(nullable NSArray *)attributes decorations:(nullable NSArray *)decorations + embeds:(nullable NSArray *)embeds { NSMutableAttributedString *store = [[NSMutableAttributedString alloc] initWithString:text ?: @""]; NSInteger length = (NSInteger)store.length; @@ -550,6 +626,58 @@ + (NSAttributedString *)attributedStringWithText:(NSString *)text } } + /* + * The embed reservations, last: they read the font the attribute loop gave + * the placeholder (for the baseline offset below), and they touch nothing + * the decoration loop wrote. Every guard here degrades a bad entry to "no + * reservation" — never a crash, never an attachment over a real character. + */ + for (id entry in embeds) { + if (![entry isKindOfClass:[NSDictionary class]]) { + continue; + } + NSDictionary *spec = (NSDictionary *)entry; + NSRange range; + if (!RNSMClampedRange(spec, length, &range) || range.length != 1) { + continue; + } + NSNumber *embedId = RNSMNumber(spec[@"embedId"]); + NSNumber *width = RNSMNumber(spec[@"width"]); + NSNumber *height = RNSMNumber(spec[@"height"]); + if (embedId == nil || width == nil || height == nil || + width.doubleValue <= 0.0 || height.doubleValue <= 0.0) { + continue; + } + // The skew guard: only ever attach over the U+FFFC placeholder the + // projection emitted. Under prop skew a clamped range can land on prose, + // and an attachment there would visually swallow a real character. + if ([store.string characterAtIndex:range.location] != 0xFFFC) { + continue; + } + /* + * The attachment sits ON the baseline by default, leaving the line's + * descent below it unused — so a card as tall as its (min = max pinned) + * line would poke out the top by exactly the descent. Dropping the origin + * to the placeholder's font descender (a negative number) aligns the + * attachment's bottom with the descent floor instead, which keeps the + * whole reservation inside the line the JS-side lineHeight attribute + * sized for it. + */ + id fontValue = [store attribute:NSFontAttributeName + atIndex:range.location + effectiveRange:nil]; + CGFloat descender = + [fontValue isKindOfClass:[UIFont class]] ? ((UIFont *)fontValue).descender : 0.0; + RNSMEmbedAttachment *attachment = [[RNSMEmbedAttachment alloc] + initWithEmbedId:embedId.integerValue + bounds:CGRectMake( + 0.0, + descender, + (CGFloat)width.doubleValue, + (CGFloat)height.doubleValue)]; + [store addAttribute:NSAttachmentAttributeName value:attachment range:range]; + } + return store; } @@ -647,6 +775,30 @@ @implementation RNSMAttributedText (Props) return decorations; } ++ (NSArray *)embedsWithProps:(const SelectableRunHostProps &)props +{ + /* + * All five members are required from JS, so unlike the sparse structs above + * every key is written unconditionally — the builder's own guards (positive + * size, 1-unit range, U+FFFC underneath) are what absorb a defaulted or + * malformed entry. Its own method for the same reason decorationsWithProps + * is: the string builder and the Fabric component view both need the + * dictionary form, and one decoder means they cannot disagree. + */ + NSMutableArray *embeds = + [NSMutableArray arrayWithCapacity:props.embeds.size()]; + for (const auto &embed : props.embeds) { + [embeds addObject:@{ + @"start" : @(embed.start), + @"end" : @(embed.end), + @"embedId" : @(embed.embedId), + @"width" : @(embed.width), + @"height" : @(embed.height), + }]; + } + return embeds; +} + + (NSAttributedString *)attributedStringWithProps:(const SelectableRunHostProps &)props { /* @@ -717,7 +869,8 @@ + (NSAttributedString *)attributedStringWithProps:(const SelectableRunHostProps return [self attributedStringWithText:text attributes:attributes - decorations:[self decorationsWithProps:props]]; + decorations:[self decorationsWithProps:props] + embeds:[self embedsWithProps:props]]; } @end diff --git a/platform/ios/SelectableRunHostView.swift b/platform/ios/SelectableRunHostView.swift index b692004..cb4792e 100644 --- a/platform/ios/SelectableRunHostView.swift +++ b/platform/ios/SelectableRunHostView.swift @@ -74,6 +74,13 @@ public final class SelectableRunHostView: UIView { /// owns the wire format, so this class emits values. @objc public var onInlinePress: ((Int, Int, Int) -> Void)? + /// Emitted per embed once layout has placed its reserved space, with the + /// embed's id and the rect in this view's coordinates — and re-emitted only + /// when the rect actually moved (see `reportEmbedRects`). A plain closure + /// for the same reason the other two are: the mounting layer owns the wire + /// format, so this class emits values. + @objc public var onEmbedLayout: ((Int, Double, Double, Double, Double) -> Void)? + /// Menu titles for the action identifiers JS may send. Unknown /// identifiers (newer JS driving an older binary) are dropped rather than /// rendered as untitled items. @@ -111,6 +118,26 @@ public final class SelectableRunHostView: UIView { /// only one. private var resolvedPressables: [Pressable] = [] + /// One embedded range: `embeds` parsed on arrival. `id` is JS's identifier + /// for the embed (its index into the prop as sent), echoed back verbatim in + /// `onEmbedLayout`; `size` is the declared reservation, reported rather + /// than re-measured so the overlay is sized by the same numbers the + /// attachment reserved. + private struct Embed { + let range: NSRange + let id: Int + let size: CGSize + } + + /// `embeds` parsed to well-formed entries, in prop order. + private var resolvedEmbeds: [Embed] = [] + + /// The last rect reported per embed id — the dedupe that keeps streaming + /// appends past a settled embed from re-announcing it every snapshot. Keyed + /// by id rather than index so a prop update that reorders entries still + /// compares each embed against its own last report. + private var lastEmbedRects: [Int: CGRect] = [:] + /// One block-chrome instruction, parsed from the `decorations` prop. Only /// the two DRAWN kinds are kept — 'columns' is layout, consumed entirely by @@ -266,6 +293,10 @@ public final class SelectableRunHostView: UIView { public override func layoutSubviews() { super.layoutSubviews() textView.frame = bounds + // A width change moves where every embed's line wraps to, with no text + // change to trigger a report — the dedupe in reportEmbedRects makes the + // no-move case one dictionary compare per embed. + reportEmbedRects() } // MARK: - Props @@ -285,6 +316,37 @@ public final class SelectableRunHostView: UIView { } } + /// Embedded ranges over `text`: an array of dictionaries with `start`/`end` + /// (always a 1-unit range over the U+FFFC placeholder JS projected), + /// `embedId`, `width`, `height`. The layout-affecting half — the invisible + /// attachment that reserves the declared size — was consumed by the string + /// builder on the layout thread, exactly like a decoration's insets: the + /// measured string arrives through State, built from these same props. Only + /// the parse-for-reporting half matters here: `reportEmbedRects` reads this + /// list to say where each reservation landed. + @objc public var embeds: NSArray = [] { + didSet { + resolvedEmbeds = embeds.compactMap { entry in + guard let dictionary = entry as? [String: Any], + let start = (dictionary["start"] as? NSNumber)?.intValue, + let end = (dictionary["end"] as? NSNumber)?.intValue, + let id = (dictionary["embedId"] as? NSNumber)?.intValue, + let width = (dictionary["width"] as? NSNumber)?.doubleValue, + let height = (dictionary["height"] as? NSNumber)?.doubleValue, + start >= 0, end == start + 1, width > 0, height > 0 else { return nil } + return Embed( + range: NSRange(location: start, length: 1), + id: id, + size: CGSize(width: width, height: height)) + } + // A changed list invalidates every previous report: an id that no + // longer exists must not suppress a future report for a reused id, and + // an embed whose size changed must re-report even if its origin did + // not move. + lastEmbedRects.removeAll() + } + } + /// Install the newly styled string. Two paths, chosen per call: /// @@ -355,6 +417,9 @@ public final class SelectableRunHostView: UIView { // Appended text can move end-anchored decorations and always moves the // measured height. setNeedsDisplay() + // Settled embeds live in the untouched prefix, so their rects almost + // never move on an append — the dedupe makes this a per-embed compare. + reportEmbedRects() return } @@ -381,6 +446,7 @@ public final class SelectableRunHostView: UIView { } // Text moved, so every decoration's geometry did too. setNeedsDisplay() + reportEmbedRects() } @objc public var selectable: Bool = true { @@ -460,6 +526,14 @@ public final class SelectableRunHostView: UIView { textView.attributedText = NSAttributedString() textView.selectedRange = NSRange(location: 0, length: 0) textView.resignFirstResponder() + // The rect dedupe is NOT prop-derived — it is layout history — so unlike + // `resolvedEmbeds` (which stays, like `resolvedDecorations` and + // `pressables`: props survive recycling) it must go: a recycled host that + // kept it would silently skip reporting a rect the next run's overlay + // happens to share, and JS would position that overlay from a rect the + // previous run reported — the same stale-state failure class as the + // selection reset above. + lastEmbedRects.removeAll() // `resolvedDecorations` is deliberately NOT cleared — it is prop-derived, // like `pressables`, and the props survive recycling. The redraw is what // matters: with the text emptied, `draw(_:)` paints nothing (it guards on @@ -467,6 +541,51 @@ public final class SelectableRunHostView: UIView { setNeedsDisplay() } + // MARK: - Embed rects + + /// Report where each embed's reserved space landed, deduped against the + /// last report per id so streaming appends past a settled embed cost one + /// rect compare instead of one event per snapshot. + /// + /// Geometry comes off the SAME TextKit stack the view draws with, through + /// the same primitives the hit test uses: `glyphRange(forCharacterRange:)` + /// then `boundingRect(forGlyphRange:in:)`, which forces layout for the + /// range if needed. Container coordinates are view points here — the + /// container's `lineFragmentPadding` and the view's `textContainerInset` + /// are both zeroed in init — plus the text view's frame origin, exactly as + /// `lineBand` adds it for decorations. + /// + /// Every embed is re-verified against the CURRENT text (in range, still + /// U+FFFC underneath), the same skew discipline as the builder: a stale + /// entry reports nothing rather than a rect over prose. + private func reportEmbedRects() { + guard let emit = onEmbedLayout, !resolvedEmbeds.isEmpty else { return } + let layoutManager = textView.layoutManager + guard layoutManager.numberOfGlyphs > 0 else { return } + let full = (textView.text ?? "") as NSString + let origin = textView.frame.origin + for embed in resolvedEmbeds { + guard embed.range.location < full.length, + full.character(at: embed.range.location) == 0xFFFC else { continue } + let glyphRange = layoutManager.glyphRange( + forCharacterRange: embed.range, actualCharacterRange: nil) + guard glyphRange.length > 0 else { continue } + var rect = layoutManager.boundingRect( + forGlyphRange: glyphRange, in: textView.textContainer) + rect.origin.x += origin.x + rect.origin.y += origin.y + if let last = lastEmbedRects[embed.id], + abs(last.origin.x - rect.origin.x) <= 0.5, + abs(last.origin.y - rect.origin.y) <= 0.5, + abs(last.width - rect.width) <= 0.5, + abs(last.height - rect.height) <= 0.5 { + continue + } + lastEmbedRects[embed.id] = rect + emit(embed.id, rect.origin.x, rect.origin.y, rect.width, rect.height) + } + } + // MARK: - Decorations /// One entry off the wire, or nil for one this binary cannot use. Colours diff --git a/platform/ios/fabric/RCTSelectableRunHostComponentView.mm b/platform/ios/fabric/RCTSelectableRunHostComponentView.mm index 0d45744..7289d21 100644 --- a/platform/ios/fabric/RCTSelectableRunHostComponentView.mm +++ b/platform/ios/fabric/RCTSelectableRunHostComponentView.mm @@ -122,6 +122,26 @@ static bool RCTSelectableRunHostDecorationsEqual( isEqualToArray:[RNSMAttributedText decorationsWithProps:rhs]]; } +/* + * Same decoded-form comparison as decorations, for the same reason: one + * decoder (RNSMAttributedText embedsWithProps) already exists for the string + * builder, so the honest equality is on what it produces rather than a + * member list that rots when the spec gains a field. + */ +static bool RCTSelectableRunHostEmbedsEqual( + const SelectableRunHostProps &lhs, + const SelectableRunHostProps &rhs) +{ + if (lhs.embeds.size() != rhs.embeds.size()) { + return false; + } + if (lhs.embeds.empty()) { + return true; + } + return [[RNSMAttributedText embedsWithProps:lhs] + isEqualToArray:[RNSMAttributedText embedsWithProps:rhs]]; +} + /* * Codegen emits no operator== for generated structs, so the prop diff below * compares by hand. Element-wise and in order, because order is identity @@ -205,6 +225,14 @@ - (instancetype)initWithFrame:(CGRect)frame _hostView.onInlinePress = ^(NSInteger start, NSInteger end, NSInteger pressableId) { [weakSelf emitInlinePressWithStart:start end:end pressableId:pressableId]; }; + /* + * Weak for the identical reason. Like `pressables`, `embeds` needs no + * default sync above: the generated default (an empty vector) and the + * host's default (an empty array) agree. + */ + _hostView.onEmbedLayout = ^(NSInteger embedId, double x, double y, double width, double height) { + [weakSelf emitEmbedLayoutWithId:embedId x:x y:y width:width height:height]; + }; /* * `contentView` is framed for free from `updateLayoutMetrics:` * (RCTViewComponentView.mm:419-421), so this class needs no @@ -272,6 +300,17 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _hostView.decorations = [RNSMAttributedText decorationsWithProps:newViewProps]; } + /* + * `embeds` is read here for the same split reason as `decorations`: the + * layout-affecting half (the attachment) was consumed by the string builder + * on the layout thread, but the rect reports come from the host view, and + * the view needs the list to know which placeholders to report on. Same + * decoder as the builder used, so the two halves cannot disagree. + */ + if (!RCTSelectableRunHostEmbedsEqual(oldViewProps, newViewProps)) { + _hostView.embeds = [RNSMAttributedText embedsWithProps:newViewProps]; + } + [super updateProps:props oldProps:oldProps]; } @@ -378,6 +417,31 @@ - (void)emitInlinePressWithStart:(NSInteger)start .pressableId = static_cast(pressableId)}); } +- (void)emitEmbedLayoutWithId:(NSInteger)embedId + x:(double)x + y:(double)y + width:(double)width + height:(double)height +{ + /* + * The nil check matters for the same recycling reason as the two above: a + * layout report racing prepareForRecycle has nowhere to go, instead of + * somewhere wrong — the host's own reset() cleared its rect dedupe, so the + * next run re-reports through a live emitter. + */ + if (!_eventEmitter) { + return; + } + + static_cast(*_eventEmitter) + .onEmbedLayout(SelectableRunHostEventEmitter::OnEmbedLayout{ + .embedId = static_cast(embedId), + .x = static_cast(x), + .y = static_cast(y), + .width = static_cast(width), + .height = static_cast(height)}); +} + @end Class SelectableRunHostCls(void) diff --git a/scripts/check-codegen.mjs b/scripts/check-codegen.mjs index 4a874ca..0114482 100644 --- a/scripts/check-codegen.mjs +++ b/scripts/check-codegen.mjs @@ -389,6 +389,18 @@ const EXPECTED_DECORATION_MEMBERS = { rowPaddingV: 'Float', }; +// The embeds struct: all five members are required from JS, but codegen +// guards every assignment the same way, so the sparse machinery applies +// verbatim. The 0.0 float sentinel cannot collide — a 0pt embed reserves +// nothing, and both hosts skip entries without a positive size. +const EXPECTED_EMBED_MEMBERS = { + start: 'int', + end: 'int', + embedId: 'int', + width: 'Float', + height: 'Float', +}; + /** * Asserts one generated array-element struct keeps the sparse sentinel * contract: expected members with expected sentinel types, no strays, no @@ -544,6 +556,11 @@ if (propsH) { EXPECTED_DECORATION_MEMBERS, 'both decoration decoders (RNSMAttributedText decorationsWithProps, RunDecorations.parse)', ); + checkSparseStruct( + 'SelectableRunHostEmbedsStruct', + EXPECTED_EMBED_MEMBERS, + 'both embed decoders (RNSMAttributedText embedsWithProps, RunEmbeds.parse)', + ); // The pressables struct. Deliberately boring — three required Int32s — so // none of the sentinel machinery above applies; what is asserted is that it @@ -634,6 +651,15 @@ if (propsH) { ' empty is the "intercept no taps" value JS sends when nothing listens,\n' + ' and it must be what a host mounted without the prop gets.', ); + expectText( + propsH, + 'std::vector embeds{};', + 'Props.h', + 'The embedded ranges arrive as a vector whose default is empty — empty is\n' + + ' "reserve nothing", which is what a host mounted without the prop (or by\n' + + ' an older JS bundle) must render: the flat text it rendered before the\n' + + ' prop existed.', + ); expectText( propsH, 'bool selectable{true};', @@ -704,6 +730,24 @@ if (eventEmittersH) { 'This is the exact signature the iOS Fabric component view calls for a\n' + ' press on a link range.', ); + for (const member of ['int embedId;', 'Float x;', 'Float y;', 'Float width;', 'Float height;']) { + expectText( + eventEmittersH, + member, + 'EventEmitters.h', + 'The OnEmbedLayout payload is the rect-report contract (docs/SELECTION.md,\n' + + ' "Event: onEmbedLayout"): one embed per event, scalar members only —\n' + + ' an array payload is not verifiably supported by codegen across the\n' + + ' whole peer range.', + ); + } + expectText( + eventEmittersH, + 'void onEmbedLayout(OnEmbedLayout value) const;', + 'EventEmitters.h', + 'This is the exact signature the iOS Fabric component view calls after\n' + + ' layout for each embed whose rect moved.', + ); } const eventEmittersCpp = read(iosSpec('EventEmitters.cpp'), 'EventEmitters.cpp'); @@ -723,6 +767,13 @@ if (eventEmittersCpp) { 'Same three-way agreement as selectionAction, for the `topInlinePress`\n' + ' registration: view config, Android event constants, and this dispatch.', ); + expectText( + eventEmittersCpp, + 'dispatchEvent("embedLayout"', + 'EventEmitters.cpp', + 'Same three-way agreement as selectionAction, for the `topEmbedLayout`\n' + + ' registration: view config, Android event constants, and this dispatch.', + ); } const shadowNodesCpp = read(iosSpec('ShadowNodes.cpp'), 'ShadowNodes.cpp'); @@ -833,6 +884,7 @@ if (managerInterface) { 'void setAttributes(T view, @Nullable ReadableArray value);', 'void setDecorations(T view, @Nullable ReadableArray value);', 'void setPressables(T view, @Nullable ReadableArray value);', + 'void setEmbeds(T view, @Nullable ReadableArray value);', 'void setSelectable(T view, boolean value);', 'void setSelectionActions(T view, @Nullable ReadableArray value);', ]) { @@ -939,6 +991,13 @@ expectText( 'This is the mapping that turns the native "inlinePress" event into the\n' + ' onInlinePress prop. Both native hosts dispatch topInlinePress.', ); +expectText( + viewConfig, + "topEmbedLayout:{registrationName:'onEmbedLayout'}", + 'view config', + 'This is the mapping that turns the native "embedLayout" event into the\n' + + ' onEmbedLayout prop. Both native hosts dispatch topEmbedLayout.', +); // §3.2: nested colours are NOT processed by the generated view config, which is // why RunHost.toNativeAttribute calls processColor itself. `attributes: true` // is codegen saying "pass this through untouched". @@ -953,6 +1012,7 @@ expectText( ); expectText(viewConfig, 'selectionActions:true', 'view config', 'The ordered action list is passed through as-is.'); expectText(viewConfig, 'pressables:true', 'view config', 'The tappable ranges are passed through as-is.'); +expectText(viewConfig, 'embeds:true', 'view config', 'The embedded ranges are passed through as-is.'); expectText( viewConfig, 'decorations:true', diff --git a/src/index.ts b/src/index.ts index 3e93850..56a7308 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,5 +39,9 @@ export * from './view/runPressables'; // And again for `RunHostProps.decorations` — `RunDecoration` is what // `NativeRunDecoration` in the codegen spec mirrors, field for field. export * from './view/runDecorations'; +// And for `RunHostProps.embeds` — `RunEmbed` is what `NativeRunEmbed` in the +// codegen spec mirrors, minus the JS-only `node`/`text` fields that never +// cross the bridge. +export * from './view/runEmbeds'; export * from './view/RunHost'; export * from './view/SelectableMarkdown'; diff --git a/src/selection/__tests__/mapSelection.test.ts b/src/selection/__tests__/mapSelection.test.ts index a0447f3..7c04c43 100644 --- a/src/selection/__tests__/mapSelection.test.ts +++ b/src/selection/__tests__/mapSelection.test.ts @@ -11,7 +11,7 @@ import type { import type { ParsedDocument } from '../../document/nodes'; import { mapSelectionToSource, projectRun } from '../mapSelection'; import { segmentRuns } from '../runs'; -import type { RunSegment } from '../runs'; +import type { EmbedLookup, RunSegment } from '../runs'; import { expectTiling, makeDoc, @@ -576,3 +576,242 @@ describe('mapSelectionToSource', () => { expect(mapSelectionToSource(tailProjected, { start: 4, end: 5 })).toBeNull(); }); }); + +describe('embeds', () => { + const claimCitations: EmbedLookup = (node) => + node.kind === 'link' && node.href.startsWith('cite://') + ? { width: 200, height: 80, text: '[1]' } + : undefined; + + const source = 'Before card.\n\n[1](cite://a)\n\nAfter card.'; + const before = plainParagraph(source, 'Before card.'); + const link: LinkNode = { + kind: 'link', + href: 'cite://a', + blocked: true, + span: spanOf(source, '[1](cite://a)'), + children: [textNode(source, '1')], + }; + const card: ParagraphNode = { + kind: 'paragraph', + span: link.span, + children: [link], + }; + const after = plainParagraph(source, 'After card.'); + const doc = makeDoc(source, [before, card, after]); + + function projectWithEmbeds() { + const runs = segmentRuns(doc, { embed: claimCitations }); + expect(runs).toHaveLength(1); + return projectRun(runs[0], doc, { embed: claimCitations }); + } + + it('projects an embedded node as exactly one U+FFFC placeholder', () => { + const projected = projectWithEmbeds(); + + expect(projected.text).toBe('Before card.\n\n\n\nAfter card.'); + expectTiling(projected); + }); + + it('gives the embed one indivisible piece over the node’s whole span', () => { + const projected = projectWithEmbeds(); + const placeholderAt = projected.text.indexOf(''); + const piece = projected.pieces.find( + (candidate) => candidate.textStart === placeholderAt, + ); + + expect(piece).toEqual({ + textStart: placeholderAt, + textEnd: placeholderAt + 1, + source: link.span, + }); + }); + + it('marks the placeholder with kind embed and its ordinal id', () => { + const projected = projectWithEmbeds(); + const placeholderAt = projected.text.indexOf(''); + + expect( + projected.marks.filter((mark) => mark.kind === 'embed'), + ).toEqual([ + { kind: 'embed', start: placeholderAt, end: placeholderAt + 1, embedId: 0 }, + ]); + }); + + it('records the node and content on projected.embeds', () => { + const projected = projectWithEmbeds(); + const placeholderAt = projected.text.indexOf(''); + + expect(projected.embeds).toEqual([ + { + embedId: 0, + start: placeholderAt, + end: placeholderAt + 1, + node: link, + content: { width: 200, height: 80, text: '[1]' }, + }, + ]); + }); + + it('projects with the same topLevel context segmentation saw', () => { + // Segmentation and projection share `embedContentFor`, and with the + // claim gated on `context.topLevel` the two must still agree: a claim + // that declines nested nodes projects the top-level block as a + // placeholder and leaves the nested instance as text. + const codeSource = '```\ntop\n```\n\n> quote\n>\n> ```\n> deep\n> ```'; + const topCode = { + kind: 'codeBlock' as const, + literal: 'top\n', + fenced: true, + closed: true, + span: spanOf(codeSource, '```\ntop\n```'), + }; + const deepCode = { + kind: 'codeBlock' as const, + literal: 'deep\n', + fenced: true, + closed: true, + span: spanOf(codeSource, '```\n> deep\n> ```'), + }; + const quote = { + kind: 'blockquote' as const, + span: spanOf(codeSource, '> quote\n>\n> ```\n> deep\n> ```'), + children: [plainParagraph(codeSource, 'quote'), deepCode], + }; + const codeDoc = makeDoc(codeSource, [topCode, quote]); + const claimTopLevelCode: EmbedLookup = (node, context) => + node.kind === 'codeBlock' && context.topLevel + ? { width: 320, height: 60 } + : undefined; + + const runs = segmentRuns(codeDoc, { embed: claimTopLevelCode }); + expect(runs).toHaveLength(1); + const projected = projectRun(runs[0], codeDoc, { embed: claimTopLevelCode }); + + expect(projected.text).toBe('\n\nquote\n\ndeep\n'); + expect(projected.embeds).toHaveLength(1); + expect(projected.embeds?.[0].node).toBe(topCode); + }); + + it('leaves projected.embeds absent when nothing is claimed', () => { + const runs = segmentRuns(doc); + const projected = projectRun(runs[0], doc); + + expect(projected.embeds).toBeUndefined(); + expect('embeds' in projected).toBe(false); + }); + + it('assigns ordinal embedIds across multiple embeds', () => { + const twoSource = '[1](cite://a) and [2](cite://b)'; + const first: LinkNode = { + kind: 'link', + href: 'cite://a', + span: spanOf(twoSource, '[1](cite://a)'), + children: [textNode(twoSource, '1')], + }; + const second: LinkNode = { + kind: 'link', + href: 'cite://b', + span: spanOf(twoSource, '[2](cite://b)'), + children: [textNode(twoSource, '2', twoSource.indexOf('[2]'))], + }; + const para: ParagraphNode = { + kind: 'paragraph', + span: { start: 0, end: twoSource.length }, + children: [first, textNode(twoSource, ' and '), second], + }; + const twoDoc = makeDoc(twoSource, [para]); + const runs = segmentRuns(twoDoc, { embed: claimCitations }); + const projected = projectRun(runs[0], twoDoc, { embed: claimCitations }); + + expect(projected.text).toBe(' and '); + expect(projected.embeds?.map((embed) => embed.embedId)).toEqual([0, 1]); + expect(projected.embeds?.[1].start).toBe(projected.text.lastIndexOf('')); + expectTiling(projected); + }); + + it('keeps a one-code-unit node’s placeholder piece unmerged', () => { + // The atomicity of an embed piece is contractual, not inferred from the + // display/source length inequality — a node whose span is exactly one + // code unit would otherwise read as linear and merge into its + // neighbours. + const tinySource = 'a&b'; + const amp = textNode(tinySource, '&'); + const para: ParagraphNode = { + kind: 'paragraph', + span: { start: 0, end: tinySource.length }, + children: [textNode(tinySource, 'a'), amp, textNode(tinySource, 'b')], + }; + const tinyDoc = makeDoc(tinySource, [para]); + const claim: EmbedLookup = (node) => + node === amp ? { width: 10, height: 10 } : undefined; + const runs = segmentRuns(tinyDoc, { embed: claim }); + const projected = projectRun(runs[0], tinyDoc, { embed: claim }); + + expect(projected.text).toBe('ab'); + expect(projected.pieces).toEqual([ + { textStart: 0, textEnd: 1, source: spanOf(tinySource, 'a') }, + { textStart: 1, textEnd: 2, source: amp.span }, + { textStart: 2, textEnd: 3, source: spanOf(tinySource, 'b') }, + ]); + }); + + it('projects an embed-only block to a non-empty run', () => { + const soloSource = '[1](cite://a)'; + const soloLink: LinkNode = { + kind: 'link', + href: 'cite://a', + span: spanOf(soloSource, soloSource), + children: [textNode(soloSource, '1')], + }; + const soloPara: ParagraphNode = { + kind: 'paragraph', + span: soloLink.span, + children: [soloLink], + }; + const soloDoc = makeDoc(soloSource, [soloPara]); + const runs = segmentRuns(soloDoc, { embed: claimCitations }); + const projected = projectRun(runs[0], soloDoc, { embed: claimCitations }); + + expect(projected.text).toBe(''); + expectTiling(projected); + }); + + it('falls through to normal projection for synthetic and incomplete nodes', () => { + const incomplete: LinkNode = { ...link, incomplete: true }; + const withIncomplete: ParagraphNode = { + kind: 'paragraph', + span: incomplete.span, + children: [incomplete], + }; + const streamDoc = makeDoc(source, [withIncomplete]); + const runs = segmentRuns(streamDoc, { embed: claimCitations }); + const projected = projectRun(runs[0], streamDoc, { embed: claimCitations }); + + // An incomplete link projects its children bare — no placeholder. + expect(projected.text).toBe('1'); + expect(projected.embeds).toBeUndefined(); + }); + + it('maps a sweep across the card to a hull covering its whole source', () => { + const projected = projectWithEmbeds(); + const span = mapSelectionToSource(projected, { + start: 0, + end: projected.text.length, + }); + + expect(span).toEqual({ start: 0, end: source.length }); + }); + + it('maps a placeholder-only selection to the node’s whole span', () => { + const projected = projectWithEmbeds(); + const placeholderAt = projected.text.indexOf(''); + + expect( + mapSelectionToSource(projected, { + start: placeholderAt, + end: placeholderAt + 1, + }), + ).toEqual(link.span); + }); +}); diff --git a/src/selection/__tests__/runs.test.ts b/src/selection/__tests__/runs.test.ts index df99f49..5f037dc 100644 --- a/src/selection/__tests__/runs.test.ts +++ b/src/selection/__tests__/runs.test.ts @@ -11,7 +11,7 @@ import type { ThematicBreakNode, } from '../../document/nodes'; import { classifyBlock, segmentRuns } from '../runs'; -import type { ClassifyBlock } from '../runs'; +import type { ClassifyBlock, EmbedLookup } from '../runs'; import { makeDoc, plainParagraph, spanOf, textNode } from './fixtures'; describe('segmentRuns', () => { @@ -621,4 +621,197 @@ describe('segmentRuns', () => { }); }); + describe('embed seam', () => { + // The chat-app case the seam exists for: a blocked citation link renders + // as a card, and the card must FLOW so a selection sweeps across it — + // the inverse of the classifyBlock claim, which ends the run. + const claimCitations: EmbedLookup = (node) => + node.kind === 'link' && node.href.startsWith('cite://') + ? { width: 200, height: 80 } + : undefined; + + const source = 'Answer text.\n\n[1](cite://a)\n\nFollow-up.'; + const answer = plainParagraph(source, 'Answer text.'); + const link: LinkNode = { + kind: 'link', + href: 'cite://a', + blocked: true, + span: spanOf(source, '[1](cite://a)'), + children: [textNode(source, '1')], + }; + const card: ParagraphNode = { + kind: 'paragraph', + span: link.span, + children: [link], + }; + const followUp = plainParagraph(source, 'Follow-up.'); + const doc = makeDoc(source, [answer, card, followUp]); + + it('keeps a run whole across an embedded inline', () => { + const runs = segmentRuns(doc, { embed: claimCitations }); + + expect(runs).toHaveLength(1); + expect(runs[0].standalone).toBe(false); + expect(runs[0].blocks).toEqual([answer, card, followUp]); + }); + + it('keeps a run whole across an embedded image — the claim beats VIEW_KINDS', () => { + const imgSource = 'Before.\n\n![alt](img.png)\n\nAfter.'; + const image: ImageNode = { + kind: 'image', + src: 'img.png', + alt: 'alt', + span: spanOf(imgSource, '![alt](img.png)'), + }; + const withImage: ParagraphNode = { + kind: 'paragraph', + span: image.span, + children: [image], + }; + const imgDoc = makeDoc(imgSource, [ + plainParagraph(imgSource, 'Before.'), + withImage, + plainParagraph(imgSource, 'After.'), + ]); + + // Unclaimed, the image forces its paragraph standalone… + expect(segmentRuns(imgDoc)).toHaveLength(3); + // …claimed as an embed, everything flows. + const runs = segmentRuns(imgDoc, { + embed: (node) => + node.kind === 'image' ? { width: 120, height: 90 } : undefined, + }); + expect(runs).toHaveLength(1); + expect(runs[0].standalone).toBe(false); + }); + + it('wins over a conflicting classifyBlock claim on the same node', () => { + const runs = segmentRuns(doc, { + embed: claimCitations, + classifyBlock: (node) => + node.kind === 'link' ? 'standalone' : undefined, + }); + + expect(runs).toHaveLength(1); + expect(runs[0].standalone).toBe(false); + }); + + it('never embeds synthetic or incomplete nodes', () => { + const incomplete: LinkNode = { ...link, incomplete: true }; + const withIncomplete: ParagraphNode = { + kind: 'paragraph', + span: incomplete.span, + children: [incomplete], + }; + // An incomplete node falls through the embed claim to the normal rules, + // under which a link-bearing paragraph flows anyway — so the property + // observable here is via classifyBlock precedence: with the embed claim + // inert, a standalone classifyBlock claim on the link stands. + const runs = segmentRuns(makeDoc(source, [answer, withIncomplete, followUp]), { + embed: claimCitations, + classifyBlock: (node) => + node.kind === 'link' ? 'standalone' : undefined, + }); + expect(runs).toHaveLength(3); + expect(runs[1].standalone).toBe(true); + }); + + it('rejects claims without a positive size', () => { + const runs = segmentRuns(doc, { + embed: (node) => + node.kind === 'link' ? { width: 0, height: 80 } : undefined, + classifyBlock: (node) => + node.kind === 'link' ? 'standalone' : undefined, + }); + // The zero-width claim is inert, so the classifyBlock claim decides. + expect(runs).toHaveLength(3); + }); + + it('behaves byte-identically to today when nothing is claimed', () => { + const withLookup = segmentRuns(doc, { embed: () => undefined }); + expect(withLookup).toEqual(segmentRuns(doc)); + }); + + it('still splits at the settled boundary', () => { + const runs = segmentRuns(doc, { + embed: claimCitations, + settledUntil: answer.span.end, + }); + + expect(runs).toHaveLength(2); + expect(runs[0].blocks).toEqual([answer]); + expect(runs[1].selectable).toBe(false); + }); + + it('does not demote an embedded lone thematic break to standalone', () => { + const hrSource = '---'; + const rule: ThematicBreakNode = { + kind: 'thematicBreak', + span: spanOf(hrSource, '---'), + }; + const hrDoc = makeDoc(hrSource, [rule]); + + // Unclaimed, a lone rule demotes (an empty run cannot draw)… + expect(segmentRuns(hrDoc)[0].standalone).toBe(true); + // …embedded, it projects a placeholder character, so it may flow. + const runs = segmentRuns(hrDoc, { + embed: (node) => + node.kind === 'thematicBreak' + ? { width: 300, height: 40 } + : undefined, + }); + expect(runs).toHaveLength(1); + expect(runs[0].standalone).toBe(false); + }); + + it('offers topLevel only for direct children of the document', () => { + // A wide-code-block claim sized against the column width must be able + // to decline a nested instance: the native hosts do not clamp declared + // width against leading margins, so a full-width reservation inside a + // list item would overflow. `context.topLevel` is that signal. + const codeSource = '```\nwide\n```\n\n- item'; + const topCode: CodeBlockNode = { + kind: 'codeBlock', + literal: 'wide\n', + fenced: true, + closed: true, + span: spanOf(codeSource, '```\nwide\n```'), + }; + const nestedCode: CodeBlockNode = { + ...topCode, + span: spanOf(codeSource, 'item'), + }; + const list: ListNode = { + kind: 'list', + ordered: false, + tight: true, + span: spanOf(codeSource, '- item'), + items: [ + { + kind: 'listItem', + span: spanOf(codeSource, '- item'), + children: [nestedCode], + }, + ], + }; + const codeDoc = makeDoc(codeSource, [topCode, list]); + + const offers: { kind: string; topLevel: boolean }[] = []; + const claimWideCode: EmbedLookup = (node, context) => { + if (node.kind !== 'codeBlock') return undefined; + offers.push({ kind: node.kind, topLevel: context.topLevel }); + return context.topLevel ? { width: 320, height: 60 } : undefined; + }; + + const runs = segmentRuns(codeDoc, { embed: claimWideCode }); + + // The top-level block was offered as such and claimed (it flows with + // the list); the nested one was offered nested and declined. + expect(offers).toContainEqual({ kind: 'codeBlock', topLevel: true }); + expect(offers).toContainEqual({ kind: 'codeBlock', topLevel: false }); + expect(offers.some((offer) => offer.topLevel)).toBe(true); + expect(runs).toHaveLength(1); + expect(runs[0].standalone).toBe(false); + }); + }); }); diff --git a/src/selection/mapSelection.ts b/src/selection/mapSelection.ts index 2ed9a17..6a11db7 100644 --- a/src/selection/mapSelection.ts +++ b/src/selection/mapSelection.ts @@ -1,11 +1,38 @@ import type { AnyNode, Block, Inline, ParsedDocument } from '../document/nodes'; import type { SourceSpan } from '../document/span'; -import type { RunSegment } from './runs'; +import type { EmbedContent, EmbedLookup, RunSegment } from './runs'; +import { embedContentFor } from './runs'; export interface ProjectedRun { text: string; pieces: RunPiece[]; marks: RunMark[]; + /** + * The run's embedded nodes, in placeholder order (which is also `embedId` + * order — the id is the index into this array). Present only when the run + * was projected with an `embed` lookup that claimed something, so every + * projection without embeds keeps its exact previous shape. + */ + embeds?: ProjectedRunEmbed[]; +} + +/** + * One embedded node as the projection recorded it: where its placeholder + * sits, which node it stands for, and the content the lookup declared — + * captured at projection time, so the lookup is consulted exactly once per + * node and everything downstream (attributes, the wire prop, copy-text + * substitution) reads the same values. + */ +export interface ProjectedRunEmbed { + /** The index of this entry — carried explicitly so a consumer holding one + * entry can still say which `embedId` it is. */ + embedId: number; + /** UTF-16 offsets of the placeholder into `ProjectedRun.text`; + * `end === start + 1` always (one U+FFFC). */ + start: number; + end: number; + node: AnyNode; + content: EmbedContent; } export interface RunPiece { @@ -54,6 +81,10 @@ export interface RunMark { * how it looks or whether tapping it does anything. */ href?: string; + /** The embed's identifier when `kind` is 'embed' — its index into + * `ProjectedRun.embeds`. Absent otherwise, same absent-not-undefined rule + * as `level` and `href`. */ + embedId?: number; } export type MarkKind = @@ -129,7 +160,23 @@ export type MarkKind = */ | 'listMarker' | 'math' - | 'html'; + | 'html' + /** + * An embedded node (`EmbedLookup` claimed it): exactly ONE character of + * projected text — the U+FFFC placeholder — whose piece maps to the node's + * whole source span as an indivisible unit. THE ONE MARK WHOSE TEXT IS A + * PLACEHOLDER, NOT PROSE: the character exists so the run has something to + * select and the host has somewhere to reserve the embed's space; the view + * overlays the consumer's element on top, and copy-text substitutes the + * declared `EmbedContent.text` for it. Carries `embedId`. + */ + | 'embed'; + +/** The placeholder an embedded node projects: U+FFFC OBJECT REPLACEMENT + * CHARACTER — the character both platforms' text systems already use to + * stand for an inline attachment. Exported for consumers that post-process + * projected or copied text themselves. */ +export const EMBED_PLACEHOLDER = ''; // Deterministic display glyphs and separators. These are part of the // projection contract: the native host renders exactly this text, and @@ -210,6 +257,16 @@ export interface ProjectRunOptions { * glyphs corrupts every downstream offset. */ glyphs?: Partial; + /** + * The embed lookup the run was segmented with. IT JOINS THE PROJECTION KEY + * EXACTLY AS GLYPHS DO: a claim replaces a node's whole projection with one + * placeholder character, so projecting the same run with a different lookup + * (or none) moves every offset after the first claimed node. Anything that + * caches a `ProjectedRun` must key on this callback's identity, and every + * reprojection of the same run — including the fallback projection inside + * `handleSelectionAction` — must be handed the same lookup. + */ + embed?: EmbedLookup; } /** @@ -239,12 +296,14 @@ export function projectRun( ), } : DEFAULT_GLYPHS; - const projector = new RunProjector(doc.source, glyphs); + const projector = new RunProjector(doc.source, glyphs, options?.embed); run.blocks.forEach((block, index) => { if (index > 0) { projector.emit(BLOCK_SEPARATOR, null); } - projector.block(block); + // `run.blocks` are direct children of the document — the only calls that + // offer the embed lookup a top-level claim. + projector.block(block, true); }); return projector.finish(); } @@ -253,6 +312,13 @@ class RunProjector { private text = ''; private readonly pieces: RunPiece[] = []; private readonly marks: RunMark[] = []; + private readonly embeds: ProjectedRunEmbed[] = []; + /** The piece the last embed pushed, so `emit`'s linear merge can refuse to + * grow it: an embed's piece is atomic BY CONTRACT, not by the length + * inequality that usually keeps a piece indivisible — a claimed node whose + * source span is exactly one code unit would otherwise read as linear and + * merge into adjacent prose. */ + private embedPiece: RunPiece | null = null; /** Current list nesting depth while emitting (0 = not inside a list). * Carried onto each 'listItem' mark as its `level`. */ private listDepth = 0; @@ -260,6 +326,7 @@ class RunProjector { constructor( private readonly source: string, private readonly glyphs: ProjectionGlyphs, + private readonly embedLookup?: EmbedLookup, ) {} finish(): ProjectedRun { @@ -269,7 +336,48 @@ class RunProjector { const marks = this.marks .slice() .sort((a, b) => a.start - b.start || b.end - a.end); - return { text: this.text, pieces: this.pieces, marks }; + const projected: ProjectedRun = { text: this.text, pieces: this.pieces, marks }; + // Optional and absent when empty, so a projection without embeds keeps + // its exact previous shape (marks and pieces are deep-compared in tests + // and serialized in debugging output). + if (this.embeds.length > 0) { + projected.embeds = this.embeds; + } + return projected; + } + + /** + * Projects a claimed node as one U+FFFC placeholder and returns true, or + * returns false to let normal projection proceed. The gate is + * `embedContentFor` — the same call segmentation makes, so the two cannot + * disagree about a claim — plus a real span for the placeholder to map to. + * + * The piece is pushed DIRECTLY, not through `emit`: an embed's piece must + * be exactly one piece covering exactly the placeholder, never merged into + * a neighbour, because `mapSelectionToSource` treats it as an indivisible + * unit (display length ≠ source length) — that is what makes a sweep + * across the card yield the node's whole markdown. The mark is pushed + * directly too: `marked()` exists for ranges a body emits, and this range + * is known outright. + */ + private tryEmbed(node: AnyNode, topLevel = false): boolean { + const content = embedContentFor(node, this.embedLookup, topLevel); + if (content === undefined) { + return false; + } + const span = this.realSpan(node); + if (span === null) { + return false; + } + const start = this.text.length; + this.text += EMBED_PLACEHOLDER; + const piece: RunPiece = { textStart: start, textEnd: this.text.length, source: span }; + this.pieces.push(piece); + this.embedPiece = piece; + const embedId = this.embeds.length; + this.marks.push({ kind: 'embed', start, end: this.text.length, embedId }); + this.embeds.push({ embedId, start, end: this.text.length, node, content }); + return true; } /** @@ -300,7 +408,7 @@ class RunProjector { const textStart = this.text.length; this.text += chunk; const last = this.pieces[this.pieces.length - 1]; - if (last) { + if (last && last !== this.embedPiece) { if (last.source === null && source === null) { last.textEnd = this.text.length; return; @@ -322,7 +430,10 @@ class RunProjector { this.pieces.push({ textStart, textEnd: this.text.length, source }); } - block(node: Block): void { + block(node: Block, topLevel = false): void { + if (this.tryEmbed(node, topLevel)) { + return; + } switch (node.kind) { case 'paragraph': this.inlines(node.children); @@ -452,6 +563,9 @@ class RunProjector { } private inline(node: Inline): void { + if (this.tryEmbed(node)) { + return; + } switch (node.kind) { case 'text': this.literal(node, node.value); diff --git a/src/selection/runs.ts b/src/selection/runs.ts index 810144b..219f797 100644 --- a/src/selection/runs.ts +++ b/src/selection/runs.ts @@ -34,6 +34,98 @@ export type BlockClass = 'flowing' | 'standalone'; */ export type ClassifyBlock = (node: AnyNode) => BlockClass | undefined; +/** + * What an embedded node contributes to the run: the layout space its overlay + * needs, and the text it stands for when a selection that swept across it is + * copied. The React element itself lives one layer up (`EmbedSpec` in the + * view) — nothing below the view layer renders. + */ +export interface EmbedContent { + /** Reserved size in points, declared up front — the reservation is + * layout-affecting and measured off the UI thread, so there is no + * measure-the-card-first feedback loop. Must be positive. */ + width: number; + height: number; + /** What `plain` shows for this embed in a copy-text payload. Absent means + * the embed contributes nothing to `plain` (its placeholder is removed); + * `markdown` always carries the node's exact source either way. */ + text?: string; +} + +/** + * Where a node sits when an embed claim consults it. `topLevel` is true only + * for a direct child of the document — the one position whose reservation + * spans the full run width. A nested node (a code block inside a list item, + * a table inside a blockquote, any inline) is offered with `topLevel: false` + * so a consumer sizing an embed against the column width can decline it: + * the native hosts do not clamp a declared width against the line's leading + * margins, so a full-width claim inside an indented context overflows the + * host to the right. + */ +export interface EmbedClaimContext { + topLevel: boolean; +} + +/** The two context values, frozen module constants: the lookup runs for + * every node of every projection, and the flag has exactly two states. */ +const TOP_LEVEL_CLAIM: EmbedClaimContext = Object.freeze({ topLevel: true }); +const NESTED_CLAIM: EmbedClaimContext = Object.freeze({ topLevel: false }); + +/** + * Consumer-supplied embed claim. Return content to claim the node as an + * embed, or `undefined` to leave it alone. A claimed node FLOWS: its block + * merges with its neighbours into one run, the projection stands the node + * down to a single U+FFFC placeholder mapped to the node's whole source + * span, and the view overlays the consumer's element on the space the host + * reserves — so one gesture selects across it, and copying a sweep that + * covers it yields the node's exact markdown. + * + * Consulted before `classifyBlock` claims and before the built-in view-kind + * rules, for blocks and inlines alike — so an image or a blocked link can be + * claimed without also being forced standalone. Like `classifyBlock`, it + * must be pure, deterministic, and referentially stable: the whole document + * is resegmented and reprojected whenever the callback's identity changes. + * Purity includes the context argument: segmentation and projection may + * consult the same node from different walks, and the claim must not depend + * on anything but `(node, context)`. + * + * Nodes that cannot be embedded are left to normal projection regardless of + * a claim: `synthetic` nodes (their text is not in the source, so there is + * no span to map the placeholder to) and `incomplete` ones (a construct the + * stream is still repairing — its span is still moving, and an overlay on + * moving text is exactly the artifact this library exists to avoid). + */ +export type EmbedLookup = ( + node: AnyNode, + context: EmbedClaimContext, +) => EmbedContent | undefined; + +/** + * The one gate for "may this node be embedded at all", returning the claimed + * content when it may. Shared by segmentation and projection so the two + * cannot disagree about a claim. Rejects synthetic and incomplete nodes (see + * `EmbedLookup`) and claims without a positive size — `!(x > 0)` rather than + * `x <= 0` so a NaN from the consumer is rejected too. + */ +export function embedContentFor( + node: AnyNode, + embed?: EmbedLookup, + topLevel = false, +): EmbedContent | undefined { + if (embed === undefined || node.synthetic === true || node.incomplete === true) { + return undefined; + } + const content = embed(node, topLevel ? TOP_LEVEL_CLAIM : NESTED_CLAIM); + if (content === undefined || !(content.width > 0) || !(content.height > 0)) { + return undefined; + } + return content; +} + +function embeddable(node: AnyNode, embed?: EmbedLookup, topLevel = false): boolean { + return embedContentFor(node, embed, topLevel) !== undefined; +} + /** * Block kinds that merge into a shared prose run. Everything else * (code blocks, tables, thematic breaks, html blocks) is standalone. @@ -116,7 +208,19 @@ const VIEW_KINDS: ReadonlySet = new Set([ 'spoiler', ]); -function containsStandalone(node: AnyNode, classify?: ClassifyBlock): boolean { +function containsStandalone( + node: AnyNode, + classify?: ClassifyBlock, + embed?: EmbedLookup, +): boolean { + // An embed claim beats everything, including a `classifyBlock` claim on the + // same node: the projection stands the whole subtree down to one + // placeholder, so nothing inside it can render a view — there is no reason + // to descend, and descending would let a nested image force standalone a + // block whose image the consumer just said flows. + if (embeddable(node, embed /* nested: this walk is always inside a block */)) { + return false; + } const claimed = classify?.(node); if (claimed !== undefined) { return claimed === 'standalone'; @@ -124,18 +228,28 @@ function containsStandalone(node: AnyNode, classify?: ClassifyBlock): boolean { if (VIEW_KINDS.has(node.kind)) { return true; } - return childrenOf(node).some((child) => containsStandalone(child, classify)); + return childrenOf(node).some((child) => containsStandalone(child, classify, embed)); } /** - * Classifies one top-level block. A consumer claim wins outright; otherwise a - * non-prose kind is standalone, and a prose kind is standalone when it - * carries a standalone construct inside — a code block nested in a list item, - * an image in a paragraph. Such a block cannot merge either: a run is one - * text tree, and the nested construct has to keep its own renderer and - * gestures. + * Classifies one top-level block. An embed claim wins first (an embedded + * block flows — that is the point of embedding); then a consumer claim wins + * outright; otherwise a non-prose kind is standalone, and a prose kind is + * standalone when it carries a standalone construct inside — a code block + * nested in a list item, an image in a paragraph. Such a block cannot merge + * either: a run is one text tree, and the nested construct has to keep its + * own renderer and gestures. */ -export function classifyBlock(block: Block, classify?: ClassifyBlock): BlockClass { +export function classifyBlock( + block: Block, + classify?: ClassifyBlock, + embed?: EmbedLookup, +): BlockClass { + // Top-level by contract — see the doc comment above; `segmentRuns` only + // ever calls this for direct children of the document. + if (embeddable(block, embed, true)) { + return 'flowing'; + } const claimed = classify?.(block); if (claimed !== undefined) { return claimed; @@ -143,7 +257,7 @@ export function classifyBlock(block: Block, classify?: ClassifyBlock): BlockClas if (!PROSE_KINDS.has(block.kind)) { return 'standalone'; } - return childrenOf(block).some((child) => containsStandalone(child, classify)) + return childrenOf(block).some((child) => containsStandalone(child, classify, embed)) ? 'standalone' : 'flowing'; } @@ -166,10 +280,11 @@ export function classifyBlock(block: Block, classify?: ClassifyBlock): BlockClas */ export function segmentRuns( doc: ParsedDocument, - opts?: { settledUntil?: number; classifyBlock?: ClassifyBlock }, + opts?: { settledUntil?: number; classifyBlock?: ClassifyBlock; embed?: EmbedLookup }, ): RunSegment[] { const settledUntil = opts?.settledUntil ?? Number.POSITIVE_INFINITY; const classify = opts?.classifyBlock; + const embed = opts?.embed; const runs: RunSegment[] = []; let pending: Block[] = []; @@ -189,7 +304,10 @@ export function segmentRuns( // 1px border-colored hairline the decoration would have. Nothing is // lost selection-wise: a rule contributes no selectable text, and any // HR with a flowing neighbour still merges and keeps the sweep intact. - if (pending.every((block) => block.kind === 'thematicBreak')) { + // An EMBEDDED thematic break is exempt: it projects a placeholder + // character, so its run is not empty — and demoting it to standalone + // would hand it to `renderBlocks`, which ignores the embed claim. + if (pending.every((block) => block.kind === 'thematicBreak' && !embeddable(block, embed, true))) { for (const block of pending) { runs.push({ span: { start: block.span.start, end: block.span.end }, @@ -215,7 +333,7 @@ export function segmentRuns( for (const block of doc.blocks) { const settled = block.span.end <= settledUntil; - if (classifyBlock(block, classify) === 'flowing') { + if (classifyBlock(block, classify, embed) === 'flowing') { if (pending.length > 0 && pendingSettled !== settled) { flushProse(); } diff --git a/src/view/RunHost.tsx b/src/view/RunHost.tsx index 9f61627..d16bddb 100644 --- a/src/view/RunHost.tsx +++ b/src/view/RunHost.tsx @@ -8,10 +8,24 @@ import { import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'; import type { RunTextAttribute } from './runAttributes'; import type { RunDecoration } from './runDecorations'; +import type { RunEmbed } from './runEmbeds'; import type { RunPressable } from './runPressables'; import { DEFAULT_SELECTION_ACTIONS } from './selectionActions'; import type { SelectionAction } from './selectionActions'; +export interface EmbedLayoutEvent { + /** The identifier `RunHost` sent with the range: its index into the + * `embeds` prop as passed, echoed back verbatim by the host. Handlers must + * bounds-check it — a report can race a prop swap by a frame, exactly like + * `pressableId`. */ + embedId: number; + /** The reserved rect in the host view's coordinate space, points. */ + x: number; + y: number; + width: number; + height: number; +} + export interface InlinePressEvent { /** The pressed range, UTF-16 offsets into the run's projected display * text, clamped against the text as currently set. Informational — the @@ -85,6 +99,22 @@ interface NativePressableRange { pressableId: number; } +/** + * `RunEmbed` with the JS-only fields stripped — the wire shape of the + * `embeds` prop. The host reserves the rect and echoes the id through + * `onEmbedLayout`; the node and the copy text never cross the bridge, same + * division of knowledge as `pressables` and its hrefs. + */ +interface NativeRunEmbedRange { + /** UTF-16 offsets into `text`, end-exclusive; end === start + 1. */ + start: number; + end: number; + /** Index into the `embeds` prop this component was given. */ + embedId: number; + width: number; + height: number; +} + interface NativeRunHostProps { text: string; /** @@ -101,10 +131,18 @@ interface NativeRunHostProps { */ decorations: readonly NativeRunDecoration[]; pressables: readonly NativePressableRange[]; + /** + * Embedded ranges over `text`. Unlike `pressables` this channel is + * LAYOUT-AFFECTING — the reservation moves where glyphs sit and how tall + * the run measures — so it is always sent when embeds exist, not gated on + * an event listener. + */ + embeds: readonly NativeRunEmbedRange[]; selectable: boolean; selectionActions: readonly SelectionAction[]; onSelectionAction?: (e: NativeSyntheticEvent) => void; onInlinePress?: (e: NativeSyntheticEvent) => void; + onEmbedLayout?: (e: NativeSyntheticEvent) => void; style?: StyleProp; testID?: string; } @@ -128,6 +166,10 @@ const NO_DECORATIONS: readonly never[] = Object.freeze([]); * default and the `NativePressableRange` wire value. */ const NO_PRESSABLES: readonly never[] = Object.freeze([]); +/** Shared empty embed list; `never[]` for the same double duty as + * NO_PRESSABLES. */ +const NO_EMBEDS: readonly never[] = Object.freeze([]); + /** * `processColor` results, keyed by the colour string that produced them. * @@ -323,6 +365,16 @@ export interface RunHostProps { * so the host never intercepts a tap it has nothing to do with. */ pressables?: readonly RunPressable[]; + /** + * Embedded ranges over `text` for the native host, from + * `resolveRunEmbeds`. Layout-affecting (the host reserves each embed's + * declared rect at its placeholder character), so the list is sent + * whenever it is non-empty — presence does not depend on an + * `onEmbedLayout` listener, though without one no overlay can ever be + * positioned. A binary that predates the prop ignores it and the + * placeholder renders as an invisible gap (see the codegen spec). + */ + embeds?: readonly RunEmbed[]; selectable: boolean; /** * Which custom items the platform selection menu offers, in order. @@ -341,6 +393,13 @@ export interface RunHostProps { * press through their own ``. */ onInlinePress?: (e: InlinePressEvent) => void; + /** + * Fired by the native host, per embed, after layout with the reserved + * rect (re-fired only when the rect moved). `embedId` is the range's index + * into `embeds`, which is how the caller gets back to the node and render + * function it kept: only offsets and sizes cross the bridge. + */ + onEmbedLayout?: (e: EmbedLayoutEvent) => void; /** * View-level style for the run's box — margins, padding, background. * @@ -382,11 +441,13 @@ export function RunHost(props: RunHostProps): ReactNode { attributes = NO_ATTRIBUTES, decorations = NO_DECORATIONS, pressables = NO_PRESSABLES, + embeds = NO_EMBEDS, selectable, selectionActions = DEFAULT_SELECTION_ACTIONS, unsettledTail = false, onSelectionAction, onInlinePress, + onEmbedLayout, style, testID, } = props; @@ -431,6 +492,21 @@ export function RunHost(props: RunHostProps): ReactNode { [pressables], ); + // The node and copy text are dropped here the way pressables drop the + // href: the host gets ranges, sizes and ids, nothing else. Memoized like + // the rest — the array is re-sent on every streamed snapshot. + const nativeEmbeds = useMemo( + () => + embeds.map((embed) => ({ + start: embed.start, + end: embed.end, + embedId: embed.embedId, + width: embed.width, + height: embed.height, + })), + [embeds], + ); + const Native = loadNativeHost(); if (!Native) { // THERE IS NO FALLBACK, DELIBERATELY, AND IT THROWS RATHER THAN RENDERING @@ -467,6 +543,13 @@ export function RunHost(props: RunHostProps): ReactNode { onEmbedLayout(event.nativeEvent) : undefined + } onInlinePress={ onInlinePress ? (event) => onInlinePress(event.nativeEvent) : undefined } diff --git a/src/view/SelectableMarkdown.tsx b/src/view/SelectableMarkdown.tsx index 5a790ba..b6f5459 100644 --- a/src/view/SelectableMarkdown.tsx +++ b/src/view/SelectableMarkdown.tsx @@ -1,12 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react'; +import { memo, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import type { ReactNode } from 'react'; import { View, useColorScheme } from 'react-native'; import type { AnyNode, Block, ParsedDocument } from '../document/nodes'; @@ -16,16 +8,26 @@ import type { EngineOptions } from '../engine/options'; import { projectRun } from '../selection/mapSelection'; import type { ProjectedRun } from '../selection/mapSelection'; import { segmentRuns } from '../selection/runs'; -import type { ClassifyBlock, RunSegment } from '../selection/runs'; +import type { + ClassifyBlock, + EmbedClaimContext, + EmbedContent, + RunSegment, +} from '../selection/runs'; import { trimTrailingPlaceholders } from '../stream/placeholders'; import type { SessionSnapshot, StreamSession } from '../stream/StreamSession'; import { RunHost } from './RunHost'; -import type { InlinePressEvent, SelectionActionEvent } from './RunHost'; +import type { + EmbedLayoutEvent, + InlinePressEvent, + SelectionActionEvent, +} from './RunHost'; import { openUrl, renderBlocks, resolveRenderers } from './renderers'; import type { RenderContext, RendererMap, RendererOverrides } from './renderers'; import { resolveRunAttributes } from './runAttributes'; import type { MarkAttribute } from './runAttributes'; import { resolveRunDecorations } from './runDecorations'; +import { resolveRunEmbeds } from './runEmbeds'; import { resolveRunPressables } from './runPressables'; import { DEFAULT_SELECTION_ACTIONS, @@ -97,6 +99,37 @@ export interface SelectableMarkdownProps { * document is resegmented whenever it changes. */ classifyBlock?: ClassifyBlock; + /** + * Claims nodes as EMBEDS: custom-rendered UIs that participate in + * cross-paragraph selection instead of ending the run the way a + * `classifyBlock: 'standalone'` claim does. A claimed node's block flows; + * the node projects as a single placeholder character mapped to its whole + * source span; the native host reserves the declared `width` × `height` + * at that character; and the returned `render` element is overlaid on the + * reserved space once the host reports where it landed. One selection + * gesture sweeps across the card; copying the sweep yields the node's + * exact markdown, and copy-text substitutes the declared `text`. + * + * Consulted for every node, blocks and inlines alike, BEFORE + * `classifyBlock` and before the built-in view-kind rules — so a claimed + * image or blocked link flows too. Return `undefined` to leave a node + * alone. Synthetic and still-streaming (`incomplete`) nodes are never + * embedded regardless of a claim, and while a run is the unsettled + * streaming tail its overlays are not mounted (the space is still + * reserved, so nothing reflows when they appear). + * + * Sizing is declared, not measured: `height` becomes the placeholder + * line's height through the attribute channel, which is what makes the + * measured run and the drawn run agree. A block-level embed (its own + * paragraph) may be any height; an inline embed must fit within its + * line's height on iOS — declare chips, not towers. + * + * Must be pure, deterministic, and referentially stable (module scope, or + * `useCallback`): the whole document is resegmented AND reprojected + * whenever the callback's identity changes, because a claim changes the + * projected text itself. + */ + embed?: EmbedRenderer; /** * Which custom actions the platform selection menu offers, in order. * Default: both. The system Copy item always remains on both platforms. @@ -171,6 +204,37 @@ export interface InlineLinkPress { end: number; } +/** + * What an embed claim declares: the reservation (`EmbedContent`) plus the + * element overlaid on it. `render` receives the claimed node and the same + * `RenderContext` the block renderers get; it runs only in the view layer — + * segmentation and projection see the object purely as `EmbedContent`, which + * is what lets the `embed` prop double as the `EmbedLookup` threaded to + * `segmentRuns`/`projectRun` without a second callback to keep in sync. + */ +export interface EmbedSpec extends EmbedContent { + render: (node: AnyNode, ctx: RenderContext) => ReactNode; +} + +/** The `embed` prop's shape. Structurally an `EmbedLookup` — every + * `EmbedSpec` is an `EmbedContent`. The context tells a claim whether the + * node is a direct child of the document (`topLevel`) — the one position a + * full-column-width reservation is safe in; see `EmbedClaimContext`. */ +export type EmbedRenderer = ( + node: AnyNode, + context: EmbedClaimContext, +) => EmbedSpec | undefined; + +/** One reported embed rect, in the run host's coordinate space. */ +interface EmbedRect { + x: number; + y: number; + width: number; + height: number; +} + +const NO_EMBED_RECTS: ReadonlyMap = new Map(); + const EMPTY_DOCUMENT: ParsedDocument = { source: '', blocks: [] }; function useSessionSnapshot(session?: StreamSession): SessionSnapshot | null { @@ -202,6 +266,7 @@ interface RunViewProps { onSelectionCopy?: (payload: SelectionCopyEvent) => void; onLinkPress?: (press: InlineLinkPress) => void; attributeForMark?: MarkAttribute; + embed?: EmbedRenderer; } function RunView(props: RunViewProps): ReactNode { @@ -216,6 +281,7 @@ function RunView(props: RunViewProps): ReactNode { onSelectionCopy, onLinkPress, attributeForMark, + embed, } = props; const ctx: RenderContext = useMemo( @@ -228,6 +294,9 @@ function RunView(props: RunViewProps): ReactNode { // the rest of the theme only styles it. A colour change must restyle // without reprojecting; a stale projection under new glyphs would corrupt // every attribute, decoration and pressable range built from it. The + // `embed` callback joins the key for the same reason: a claim replaces a + // node's projection with a placeholder character, so a different lookup is + // different projected text. const { bullet, taskChecked, taskUnchecked } = theme.glyphs; const projected = useMemo( () => @@ -235,8 +304,9 @@ function RunView(props: RunViewProps): ReactNode { ? null : projectRun(run, doc, { glyphs: { bullet, taskChecked, taskUnchecked }, + embed, }), - [run, doc, bullet, taskChecked, taskUnchecked], + [run, doc, bullet, taskChecked, taskUnchecked, embed], ); // Theme resolution for the native host. Memoized separately from the @@ -265,6 +335,52 @@ function RunView(props: RunViewProps): ReactNode { [projected], ); + // The run's embedded ranges, for the native host and the overlay below. + // Derived from the projection alone, like pressables. + const runEmbeds = useMemo( + () => (projected ? resolveRunEmbeds(projected) : undefined), + [projected], + ); + + // Reported rects, keyed by embedId and OWNED BY the projection they were + // reported against: embedIds are per-projection ordinals, so a rect that + // arrived for a previous projection must never position an overlay over + // the current one. Tying ownership to `projected`'s identity drops stale + // rects at read time, with no effect and no extra render on a swap. + const [embedRects, setEmbedRects] = useState<{ + owner: ProjectedRun | null; + rects: ReadonlyMap; + }>({ owner: null, rects: NO_EMBED_RECTS }); + const rects = + embedRects.owner === projected ? embedRects.rects : NO_EMBED_RECTS; + + const onNativeEmbedLayout = useCallback( + (event: EmbedLayoutEvent) => { + // Bounds-check the id against the list this projection sent — the + // pressableId discipline: a report can race a prop swap by a frame. + if ( + !projected?.embeds || + !Number.isInteger(event.embedId) || + event.embedId < 0 || + event.embedId >= projected.embeds.length + ) { + return; + } + setEmbedRects((previous) => { + const rects = new Map( + previous.owner === projected ? previous.rects : NO_EMBED_RECTS, + ); + rects.set(event.embedId, { + x: event.x, + y: event.y, + width: event.width, + height: event.height, + }); + return { owner: projected, rects }; + }); + }, + [projected], + ); // The native half of link presses. `pressableId` is the index RunHost // assigned when it sent the ranges — which is the index into this same @@ -316,12 +432,16 @@ function RunView(props: RunViewProps): ReactNode { // The same glyphs `projected` was built with, so the payload's // `plain` shows the markers the user saw on screen. glyphs: theme.glyphs, + // And the same embed lookup, for the same reason — `projected` is + // supplied so the fallback reprojection should never run, but if it + // ever does it must not run with different offsets. + embed, }); if (payload) { onSelectionCopy(payload); } }, - [onSelectionCopy, projected, doc, run, theme.glyphs], + [onSelectionCopy, projected, doc, run, theme.glyphs, embed], ); if (run.standalone) { @@ -330,20 +450,73 @@ function RunView(props: RunViewProps): ReactNode { ); } - return ( + const host = ( ); + + // Runs without embeds keep the exact structure they always had — no + // wrapper, margin on the host. A run WITH embeds gains a relatively + // positioned wrapper (the margin moves onto it, so layout is unchanged) + // holding one absolutely positioned overlay per embed whose rect the host + // has reported. The overlay is a SIBLING of the host, not a child: the + // native component is a leaf on both architectures and cannot mount React + // children. `pointerEvents="box-none"` keeps the positioning wrapper from + // swallowing touches around the card; the card itself owns its own area — + // which also means a long-press ON the card starts no selection, the + // documented trade for it being tappable. + // + // While the run is the unsettled streaming tail no overlay mounts at all: + // repair can rewrite the tail's text every tick, and a card sliding around + // over moving prose is the artifact this library exists to avoid. The + // reservation is native either way, so nothing reflows when the run + // settles and the card appears. + if (!runEmbeds?.length || !projected?.embeds) { + return host; + } + return ( + + {host} + {!unsettledTail && + projected.embeds.map((entry) => { + const rect = rects.get(entry.embedId); + if (!rect) { + return null; + } + const spec = entry.content as Partial; + if (typeof spec.render !== 'function') { + return null; + } + return ( + + {spec.render(entry.node, ctx)} + + ); + })} + + ); } function sameBlockIdentity(a: Block[], b: Block[]): boolean { @@ -400,7 +573,8 @@ function runPropsEqual(prev: RunViewProps, next: RunViewProps): boolean { sameActionList(prev.selectionActions, next.selectionActions) && prev.onSelectionCopy === next.onSelectionCopy && prev.onLinkPress === next.onLinkPress && - prev.attributeForMark === next.attributeForMark + prev.attributeForMark === next.attributeForMark && + prev.embed === next.embed ); } @@ -416,6 +590,7 @@ export function SelectableMarkdown(props: SelectableMarkdownProps): ReactNode { colorScheme, renderers, classifyBlock, + embed, selectionActions, onSelectionCopy, onLinkPress, @@ -474,8 +649,8 @@ export function SelectableMarkdown(props: SelectableMarkdownProps): ReactNode { }, [doc, streaming]); const runs = useMemo( - () => segmentRuns(visibleDoc, { settledUntil, classifyBlock }), - [visibleDoc, settledUntil, classifyBlock], + () => segmentRuns(visibleDoc, { settledUntil, classifyBlock, embed }), + [visibleDoc, settledUntil, classifyBlock, embed], ); // The gap below a PROSE run that abuts another PROSE run is the height of @@ -507,6 +682,7 @@ export function SelectableMarkdown(props: SelectableMarkdownProps): ReactNode { ; +/** + * One embedded range over `text`, mirroring `RunEmbed` in `./runEmbeds` — + * except that, exactly as with pressables, the semantics stay in JS. The host + * never learns what the embed *is*: it reserves `width` × `height` points of + * layout space at the U+FFFC placeholder character JS projected at + * `[start, end)` (an `NSTextAttachment` on iOS, a `ReplacementSpan` on + * Android — both applied inside the shared string builder, so measurement and + * drawing cannot disagree about the reservation), reports where that space + * landed through `onEmbedLayout`, and JS positions the consumer's React view + * over it. The node, the render function and the copy text never cross the + * bridge. + * + * The sentinel rules documented on `NativeRunTextAttribute` apply: no + * booleans, no string enums. `width`/`height` are required from JS and a + * `0.0` sentinel cannot collide — a 0pt embed reserves nothing and is + * meaningless, so hosts skip entries without a positive size, which is also + * what absorbs a malformed entry from a newer JS. + */ +type NativeRunEmbed = Readonly<{ + /** UTF-16 offsets into `text`, end-exclusive; always `end === start + 1`, + * covering the single U+FFFC placeholder the projection emitted. Hosts + * must verify the character really is U+FFFC before attaching — under + * version skew a stale offset must degrade to "no reservation", never to + * swallowing a real character. */ + start: Int32; + end: Int32; + /** JS's identifier for the embed — its index into the `embeds` array as + * sent, carried explicitly for the same reason `pressableId` is. */ + embedId: Int32; + /** Declared size in points. Layout-affecting: the same values reach the + * measurer and the view through this one prop. */ + width: Float; + height: Float; +}>; + +/** + * Payload of `onEmbedLayout`: where one embed's reserved space landed, in the + * host view's coordinate space, points. Fired per embed (scalar payload — an + * array-of-objects event payload is not verifiably supported by codegen at + * the bottom of the peer range) after layout, and re-fired only when the rect + * actually moved: hosts dedupe against the last report per `embedId`, so + * streaming appends past a settled embed do not re-announce it every + * snapshot. + */ +type EmbedLayoutEvent = Readonly<{ + embedId: Int32; + x: Float; + y: Float; + width: Float; + height: Float; +}>; + /** * Payload of `onInlinePress`: the pressable range the tap landed on, clamped * against the current `text` exactly like selection offsets, plus the @@ -298,6 +350,18 @@ export interface NativeProps extends ViewProps { * but inert text — exactly the behaviour that predates the prop. */ pressables?: ReadonlyArray; + /** + * Embedded ranges over `text`: each reserves its declared rect at the + * U+FFFC placeholder JS projected there, so a consumer's React view can be + * overlaid while selection sweeps across the run uninterrupted. Unlike + * `pressables` this prop is layout-affecting, so `RunHost` sends it + * whenever embeds exist rather than gating on a listener. An older binary + * that predates this prop ignores it: the placeholder renders as an + * invisible one-character gap (its attribute range carries a transparent + * colour), no `onEmbedLayout` fires, and no overlay mounts — degraded, and + * selection mapping stays exact. + */ + embeds?: ReadonlyArray; /** * Whether the platform selection UI is enabled for this run. Defaults to * true so that a host mounted without the prop is selectable, which is the @@ -329,6 +393,9 @@ export interface NativeProps extends ViewProps { onSelectionAction?: DirectEventHandler; /** Fired when a single tap lands inside one of `pressables`. */ onInlinePress?: DirectEventHandler; + /** Fired per embed after layout with the reserved rect; re-fired only when + * the rect moved. */ + onEmbedLayout?: DirectEventHandler; } /** diff --git a/src/view/runAttributes.test.ts b/src/view/runAttributes.test.ts index 05d6471..ff86650 100644 --- a/src/view/runAttributes.test.ts +++ b/src/view/runAttributes.test.ts @@ -452,3 +452,141 @@ describeNative('resolveRunAttributes', () => { * projection object alone, so these cases hand-build one — which also keeps * them running on a machine with no compiled addon. */ +describe('embed geometry attributes', () => { + const embedNode = { kind: 'link', span: { start: 4, end: 17 } }; + const projectedWithEmbed: ProjectedRun = { + text: 'See  here.', + pieces: [ + { textStart: 0, textEnd: 4, source: { start: 0, end: 4 } }, + { textStart: 4, textEnd: 5, source: { start: 4, end: 17 } }, + { textStart: 5, textEnd: 11, source: { start: 17, end: 23 } }, + ], + marks: [{ kind: 'embed', start: 4, end: 5, embedId: 0 }], + embeds: [ + { + embedId: 0, + start: 4, + end: 5, + node: embedNode as never, + content: { width: 200, height: 80, text: '[1]' }, + }, + ], + }; + + test('appends a transparent, height-carrying attribute over the placeholder', () => { + const attributes = resolveRunAttributes(projectedWithEmbed, defaultTheme); + + expect(attributes[attributes.length - 1]).toEqual({ + start: 4, + end: 5, + color: 'transparent', + lineHeight: 80, + }); + }); + + test('the geometry attribute sits after every mark attribute, so it wins innermost', () => { + const withHeading: ProjectedRun = { + ...projectedWithEmbed, + marks: [ + { kind: 'heading', start: 0, end: 11, level: 1 }, + ...projectedWithEmbed.marks, + ], + }; + const attributes = resolveRunAttributes(withHeading, defaultTheme); + const geometryIndex = attributes.findIndex( + (attribute) => attribute.lineHeight === 80, + ); + const headingIndex = attributes.findIndex( + (attribute) => attribute.fontWeight === defaultTheme.headings.weight, + ); + + expect(geometryIndex).toBeGreaterThan(headingIndex); + }); + + test('attributeForMark never sees the embed mark and cannot drop the geometry', () => { + const seen: string[] = []; + const attributes = resolveRunAttributes( + projectedWithEmbed, + defaultTheme, + (mark) => { + seen.push(mark.kind); + // "Suppress everything" — the geometry attribute must survive it. + return {}; + }, + ); + + expect(seen).not.toContain('embed'); + expect(attributes).toContainEqual({ + start: 4, + end: 5, + color: 'transparent', + lineHeight: 80, + }); + }); + + test('a projection without embeds gains no geometry attribute', () => { + const bare: ProjectedRun = { + text: projectedWithEmbed.text, + pieces: projectedWithEmbed.pieces, + marks: [], + }; + const attributes = resolveRunAttributes(bare, defaultTheme); + + expect(attributes).toHaveLength(1); + expect(attributes[0].start).toBe(0); + }); +}); + +describe('embed geometry line-height floor', () => { + const chipProjection = (height: number): ProjectedRun => ({ + text: 'See  here.', + pieces: [ + { textStart: 0, textEnd: 4, source: { start: 0, end: 4 } }, + { textStart: 4, textEnd: 5, source: { start: 4, end: 17 } }, + { textStart: 5, textEnd: 11, source: { start: 17, end: 23 } }, + ], + marks: [{ kind: 'embed', start: 4, end: 5, embedId: 0 }], + embeds: [ + { + embedId: 0, + start: 4, + end: 5, + node: { kind: 'link', span: { start: 4, end: 17 } } as never, + content: { width: 40, height }, + }, + ], + }); + + test('a chip shorter than the line never shrinks it', () => { + // Line height clamps in BOTH directions on both platforms, so a 10pt + // chip must not squash the body line around it. + const attributes = resolveRunAttributes(chipProjection(10), defaultTheme); + const geometry = attributes[attributes.length - 1]; + const bodyLineHeight = + defaultTheme.fonts.baseSize * defaultTheme.fonts.lineHeight; + + expect(geometry.lineHeight).toBe(bodyLineHeight); + }); + + test('a chip inside a heading floors at the heading line height', () => { + const projected = chipProjection(10); + projected.marks = [ + { kind: 'heading', start: 0, end: 11, level: 1 }, + ...projected.marks, + ]; + const attributes = resolveRunAttributes(projected, defaultTheme); + const geometry = attributes[attributes.length - 1]; + const heading = attributes.find( + (attribute) => attribute.fontWeight === defaultTheme.headings.weight, + ); + + expect(geometry.lineHeight).toBe(heading?.lineHeight); + }); + + test('a card taller than the line raises it to the declared height', () => { + const attributes = resolveRunAttributes(chipProjection(200), defaultTheme); + const geometry = attributes[attributes.length - 1]; + + expect(geometry.lineHeight).toBe(200); + }); +}); diff --git a/src/view/runAttributes.ts b/src/view/runAttributes.ts index 6497e08..8d159d2 100644 --- a/src/view/runAttributes.ts +++ b/src/view/runAttributes.ts @@ -178,6 +178,14 @@ function styleForMark(mark: RunMark, theme: MarkdownTheme): Omit= embed.end && + attribute.lineHeight > floor + ) { + floor = attribute.lineHeight; + } + } + out.push({ + start: embed.start, + end: embed.end, + color: 'transparent', + lineHeight: Math.max(embed.content.height, floor), + }); + } + } return out; } diff --git a/src/view/runDecorations.test.ts b/src/view/runDecorations.test.ts index c8ea88f..d5786b1 100644 --- a/src/view/runDecorations.test.ts +++ b/src/view/runDecorations.test.ts @@ -349,3 +349,48 @@ describeNative('resolveRunDecorations', () => { } }); }); + +/* + * Embeds and decorations: an embed is a character-level reservation, not a + * paragraph inset, so it must produce NO decoration of its own and must not + * perturb the 'indent' segmentation around it — an embed inside a list item + * indents with its item (see the note on `insetSegments`). + */ +describeNative('embeds', () => { + const claim = (node: { kind: string }) => + node.kind === 'link' ? { width: 120, height: 40 } : undefined; + + function projectWithEmbed(source: string): ProjectedRun { + const doc = parseDocument(source, EVERYTHING); + const run = segmentRuns(doc, { embed: claim }).find((r) => !r.standalone); + if (!run) throw new Error(`no prose run in ${JSON.stringify(source)}`); + return projectRun(run, doc, { embed: claim }); + } + + test('an embed emits no decoration and leaves list indents intact', () => { + const source = '- first [x](https://example.com/a) rest\n- second item\n'; + const projected = projectWithEmbed(source); + const placeholderAt = projected.text.indexOf('\uFFFC'); + expect(placeholderAt).toBeGreaterThan(-1); + + const decorations = resolveRunDecorations(projected, defaultTheme); + + // Nothing new: only the two items' indent entries. + expect(decorations.every((d) => d.kind === 'indent')).toBe(true); + // The placeholder is covered by its item's indent — it moves WITH the + // item rather than opting out of it the way a code-block island does. + expect( + decorations.some((d) => d.start <= placeholderAt && d.end > placeholderAt), + ).toBe(true); + // Disjointness holds with the embed in place (the Android sum guard). + const sorted = [...decorations].sort((a, b) => a.start - b.start); + for (let i = 1; i < sorted.length; i += 1) { + expect(sorted[i].start).toBeGreaterThanOrEqual(sorted[i - 1].end); + } + // And decorating never moves the text. + for (const decoration of decorations) { + expect(decoration.start).toBeGreaterThanOrEqual(0); + expect(decoration.end).toBeLessThanOrEqual(projected.text.length); + } + }); +}); diff --git a/src/view/runDecorations.ts b/src/view/runDecorations.ts index 9bc92c0..ee2b609 100644 --- a/src/view/runDecorations.ts +++ b/src/view/runDecorations.ts @@ -400,6 +400,14 @@ interface InsetSegment { * paragraphs would assign-and-lose on iOS but SUM on Android. A code * block or table inside a list item therefore renders flush, exactly like * a top-level one — the same on both platforms. + * + * 'embed' marks are deliberately NOT islands and need no handling here at + * all: an embed is a character-level reservation (an attachment/replacement + * span on its one placeholder character), not a paragraph inset — it writes + * no paragraph style and no leading margin, so nothing exists for an + * 'indent' entry to sum with or assign over. An embed inside a list item + * SHOULD be indented with its item, which is exactly what leaving it inside + * the item's segment produces. */ function insetSegments(projected: ProjectedRun): InsetSegment[] { const items: { start: number; end: number; level: number }[] = []; diff --git a/src/view/runEmbeds.test.ts b/src/view/runEmbeds.test.ts new file mode 100644 index 0000000..b8914aa --- /dev/null +++ b/src/view/runEmbeds.test.ts @@ -0,0 +1,79 @@ +/** + * The embed list the native host reserves space from, and the projection + * entries it is built from. Pure reshaping — `resolveRunEmbeds` must never + * invent, drop, or reorder an embed, because `embedId` is the index the host + * echoes back through `onEmbedLayout` and the view resolves overlays with. + */ + +import type { AnyNode } from '../document/nodes'; +import type { ProjectedRun, ProjectedRunEmbed } from '../selection/mapSelection'; +import { resolveRunEmbeds } from './runEmbeds'; + +const node = (kind: string): AnyNode => + ({ kind, span: { start: 0, end: 1 } }) as AnyNode; + +function projectedWith(embeds?: ProjectedRunEmbed[]): ProjectedRun { + const projected: ProjectedRun = { text: 'ab', pieces: [], marks: [] }; + if (embeds) projected.embeds = embeds; + return projected; +} + +describe('resolveRunEmbeds', () => { + test('a projection without embeds resolves to an empty list', () => { + expect(resolveRunEmbeds(projectedWith())).toEqual([]); + }); + + test('reshapes each entry, keeping ids and order', () => { + const first = node('link'); + const second = node('image'); + const embeds = resolveRunEmbeds( + projectedWith([ + { + embedId: 0, + start: 1, + end: 2, + node: first, + content: { width: 200, height: 80, text: '[1]' }, + }, + { + embedId: 1, + start: 5, + end: 6, + node: second, + content: { width: 120, height: 90 }, + }, + ]), + ); + + expect(embeds).toEqual([ + { + start: 1, + end: 2, + embedId: 0, + width: 200, + height: 80, + text: '[1]', + node: first, + }, + { start: 5, end: 6, embedId: 1, width: 120, height: 90, node: second }, + ]); + // The invariant the view and the host both index on. + embeds.forEach((embed, index) => expect(embed.embedId).toBe(index)); + }); + + test('an absent copy text stays absent rather than riding as undefined', () => { + const [embed] = resolveRunEmbeds( + projectedWith([ + { + embedId: 0, + start: 1, + end: 2, + node: node('link'), + content: { width: 10, height: 10 }, + }, + ]), + ); + + expect('text' in embed).toBe(false); + }); +}); diff --git a/src/view/runEmbeds.ts b/src/view/runEmbeds.ts new file mode 100644 index 0000000..ea37a81 --- /dev/null +++ b/src/view/runEmbeds.ts @@ -0,0 +1,70 @@ +import type { AnyNode } from '../document/nodes'; +import type { ProjectedRun } from '../selection/mapSelection'; + +/** + * One embedded range of a projected run: the placeholder's display range, + * the declared reservation, and the node the embed stands for. + * + * WHY THIS EXISTS. The native selection host renders the whole run as one + * platform text view, so a custom UI that used to force its block + * `standalone` broke the run into separate selection scopes — a gesture + * could not sweep across a citation card, and the card's block lost + * `onSelectionCopy` entirely. With embeds the host instead reserves + * `width` × `height` points at the run's U+FFFC placeholder and reports the + * reserved rect back through `onEmbedLayout`; the view overlays the + * consumer's React element there, and selection flows across as if the card + * were one character — which, to the piece table, it is. + * + * Derived from `ProjectedRun.embeds` rather than re-consulted from the + * consumer's lookup so it cannot disagree with what the run projected: the + * ranges here are exactly the 'embed' marks, the sizes are the ones the + * geometry attribute in `runAttributes` reserves height for, and the ids are + * the ones `onEmbedLayout` echoes back. Only `start`/`end`/`embedId`/ + * `width`/`height` ever cross the bridge — the node and the copy text stay + * in JS, the same division of knowledge pressables use for hrefs. + */ +export interface RunEmbed { + /** UTF-16 offsets into `ProjectedRun.text`, end-exclusive; + * `end === start + 1` (one U+FFFC). */ + start: number; + end: number; + /** The identifier the host echoes back through `onEmbedLayout` — the index + * of this entry in the resolved list, which is also its index in + * `ProjectedRun.embeds`. */ + embedId: number; + /** Declared reservation in points. */ + width: number; + height: number; + /** What this embed contributes to a copy-text payload; absent means the + * placeholder is removed from `plain`. Never crosses the bridge. */ + text?: string; + /** The embedded node, for the view layer to hand to the consumer's render + * function. Never crosses the bridge. */ + node: AnyNode; +} + +/** + * The embedded ranges of a projected run, in placeholder order — which is + * `embedId` order by construction, so `out[id].embedId === id` and an + * `onEmbedLayout` event indexes this list directly (bounds-checked, the + * `pressableId` discipline). + */ +export function resolveRunEmbeds(projected: ProjectedRun): RunEmbed[] { + if (projected.embeds === undefined) { + return []; + } + return projected.embeds.map((embed) => { + const out: RunEmbed = { + start: embed.start, + end: embed.end, + embedId: embed.embedId, + width: embed.content.width, + height: embed.content.height, + node: embed.node, + }; + if (embed.content.text !== undefined) { + out.text = embed.content.text; + } + return out; + }); +} diff --git a/src/view/selectionActions.test.ts b/src/view/selectionActions.test.ts index b1de599..d29bf83 100644 --- a/src/view/selectionActions.test.ts +++ b/src/view/selectionActions.test.ts @@ -6,7 +6,7 @@ import { } from '../engine/native/__tests__/support'; import { projectRun } from '../selection/mapSelection'; import { segmentRuns } from '../selection/runs'; -import type { RunSegment } from '../selection/runs'; +import type { EmbedLookup, RunSegment } from '../selection/runs'; import { DEFAULT_SELECTION_ACTIONS, handleSelectionAction, @@ -298,3 +298,146 @@ describeNative('DEFAULT_SELECTION_ACTIONS', () => { expect(Object.isFrozen(DEFAULT_SELECTION_ACTIONS)).toBe(true); }); }); + +/* + * Embeds in the copy path. The claimed link projects as one U+FFFC + * placeholder; `markdown` maps through its indivisible piece to the node's + * whole source, and `plain` substitutes the declared text (or removes the + * placeholder when none was declared). + */ +describeNative('handleSelectionAction with embeds', () => { + const claim: EmbedLookup = (node) => + node.kind === 'link' && node.href.startsWith('https://cite.example/') + ? { width: 200, height: 80, text: '[1]' } + : undefined; + + function docWithEmbedRun(source: string, embed: EmbedLookup = claim) { + const doc = parseDocument(source); + const runs = segmentRuns(doc, { embed }); + expect(runs).toHaveLength(1); + const run = runs[0]; + const projected = projectRun(run, doc, { embed }); + return { doc, run, projected }; + } + + test('copy-text substitutes the declared text for the placeholder', () => { + const source = 'See [one](https://cite.example/a) here.'; + const { doc, run, projected } = docWithEmbedRun(source); + expect(projected.text).toBe('See  here.'); + + const payload = handleSelectionAction( + doc, + run, + { start: 0, end: projected.text.length, action: 'copy-text' }, + { projected, embed: claim }, + ); + + expect(payload?.plain).toBe('See [1] here.'); + expect(payload?.markdown).toBe(source); + expect(payload?.span).toEqual({ start: 0, end: source.length }); + }); + + test('an embed without declared text is removed from plain', () => { + const source = 'See [one](https://cite.example/a) here.'; + const noText: EmbedLookup = (node) => + node.kind === 'link' && node.href.startsWith('https://cite.example/') + ? { width: 200, height: 80 } + : undefined; + const { doc, run, projected } = docWithEmbedRun(source, noText); + + const payload = handleSelectionAction( + doc, + run, + { start: 0, end: projected.text.length, action: 'copy-text' }, + { projected, embed: noText }, + ); + + expect(payload?.plain).toBe('See here.'); + expect(payload?.markdown).toBe(source); + }); + + test('substitutes multiple embeds right-to-left, edges included', () => { + const source = + '[a](https://cite.example/a) mid [b](https://cite.example/b)'; + const numbered: EmbedLookup = (() => { + let next = 0; + const byHref = new Map(); + return ((node) => { + if ( + node.kind !== 'link' || + !node.href.startsWith('https://cite.example/') + ) { + return undefined; + } + if (!byHref.has(node.href)) { + next += 1; + byHref.set(node.href, `[${next}]`); + } + return { width: 100, height: 40, text: byHref.get(node.href) }; + }) as EmbedLookup; + })(); + const { doc, run, projected } = docWithEmbedRun(source, numbered); + expect(projected.text).toBe(' mid '); + + const payload = handleSelectionAction( + doc, + run, + { start: 0, end: projected.text.length, action: 'copy-text' }, + { projected, embed: numbered }, + ); + + expect(payload?.plain).toBe('[1] mid [2]'); + expect(payload?.markdown).toBe(source); + }); + + test('a selection excluding the placeholder substitutes nothing', () => { + const source = 'See [one](https://cite.example/a) here.'; + const { doc, run, projected } = docWithEmbedRun(source); + + const payload = handleSelectionAction( + doc, + run, + { start: 0, end: 4, action: 'copy-text' }, + { projected, embed: claim }, + ); + + expect(payload?.plain).toBe('See '); + expect(payload?.markdown).toBe('See '); + }); + + test('a placeholder-only selection copies the node’s whole markdown', () => { + const source = 'See [one](https://cite.example/a) here.'; + const { doc, run, projected } = docWithEmbedRun(source); + const placeholderAt = projected.text.indexOf(''); + + const payload = handleSelectionAction( + doc, + run, + { start: placeholderAt, end: placeholderAt + 1, action: 'copy-markdown' }, + { projected, embed: claim }, + ); + + expect(payload?.plain).toBe('[1]'); + expect(payload?.markdown).toBe('[one](https://cite.example/a)'); + }); + + test('the fallback projection with ctx.embed matches the precomputed one', () => { + const source = 'See [one](https://cite.example/a) here.'; + const { doc, run, projected } = docWithEmbedRun(source); + + const withProjected = handleSelectionAction( + doc, + run, + { start: 2, end: projected.text.length - 2, action: 'copy-markdown' }, + { projected, embed: claim }, + ); + const withFallback = handleSelectionAction( + doc, + run, + { start: 2, end: projected.text.length - 2, action: 'copy-markdown' }, + { embed: claim }, + ); + + expect(withFallback).toEqual(withProjected); + }); +}); diff --git a/src/view/selectionActions.ts b/src/view/selectionActions.ts index d6c0e21..a0d2165 100644 --- a/src/view/selectionActions.ts +++ b/src/view/selectionActions.ts @@ -2,7 +2,7 @@ import type { ParsedDocument } from '../document/nodes'; import type { SourceSpan } from '../document/span'; import { mapSelectionToSource, projectRun } from '../selection/mapSelection'; import type { ProjectedRun, ProjectionGlyphs } from '../selection/mapSelection'; -import type { RunSegment } from '../selection/runs'; +import type { EmbedLookup, RunSegment } from '../selection/runs'; /** * The copy actions the selection menu can offer. Identifiers cross the JS ↔ @@ -22,8 +22,13 @@ export interface SelectionCopyEvent { /** * The projected display text the user visually selected — exactly * `ProjectedRun.text.slice(start, end)`, synthetic glyphs (bullets, - * separators) included. Byte-for-byte what the platform's own Copy would - * yield. + * separators) included — with ONE amendment: each embed placeholder + * (U+FFFC) inside the slice is replaced by that embed's declared + * `EmbedContent.text`, or removed when none was declared. Without embeds + * this is byte-for-byte what the platform's own Copy would yield; with + * them, the system Copy still carries the raw placeholder (the platform's + * native behaviour for attachments) while this payload carries the text + * the consumer said the card stands for. */ plain: string; /** The exact markdown source slice for the mapped span. */ @@ -49,6 +54,16 @@ export interface SelectionActionContext { * Unset means the projection defaults. */ glyphs?: Partial; + /** + * The embed lookup the run was segmented and projected with. The same rule + * as `glyphs`, for the same reason: an embed claim replaces a node's whole + * projection with one placeholder character, so a fallback projection built + * without it has different offsets everywhere after the first claimed node + * — and would silently map the user's selection through the wrong piece + * table. `` threads this for you; hand-rolled callers + * that pass an `embed` prop must too. + */ + embed?: EmbedLookup; } /** @@ -77,7 +92,9 @@ export function handleSelectionAction( if (!Number.isFinite(event.start) || !Number.isFinite(event.end)) { return null; } - const projected = ctx?.projected ?? projectRun(run, doc, { glyphs: ctx?.glyphs }); + const projected = + ctx?.projected ?? + projectRun(run, doc, { glyphs: ctx?.glyphs, embed: ctx?.embed }); const start = Math.max(0, Math.min(event.start, event.end)); const end = Math.min( projected.text.length, @@ -109,9 +126,39 @@ export function handleSelectionAction( Math.max(0, Math.min(span.start, span.end)), Math.min(doc.source.length, Math.max(span.start, span.end)), ); - const plain = projected.text.slice(start, end); + const plain = substituteEmbeds(projected, start, end); const action: SelectionAction = event.action === 'copy-text' ? 'copy-text' : 'copy-markdown'; return { action, plain, markdown, span }; } + +/** + * The display slice with each embed placeholder replaced by its declared + * text (or removed — see {@link SelectionCopyEvent.plain}). Substitutes + * RIGHT-TO-LEFT so earlier placeholders' offsets are still valid while later + * ones are being replaced; embeds are recorded in ascending placeholder + * order, so a reversed walk is the descending one. + */ +function substituteEmbeds( + projected: ProjectedRun, + start: number, + end: number, +): string { + let plain = projected.text.slice(start, end); + const embeds = projected.embeds; + if (embeds === undefined) { + return plain; + } + for (let i = embeds.length - 1; i >= 0; i -= 1) { + const embed = embeds[i]; + if (embed.start < start || embed.end > end) { + continue; + } + plain = + plain.slice(0, embed.start - start) + + (embed.content.text ?? '') + + plain.slice(embed.end - start); + } + return plain; +}