diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index b26143d4..96dff18d 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -17,12 +17,15 @@ import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.common.FeatureFlags import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer import com.swmansion.enriched.markdown.utils.common.RenderedSegment +import com.swmansion.enriched.markdown.utils.common.SegmentReconciler +import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter +import com.swmansion.enriched.markdown.utils.common.TableStreamingMode import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments +import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors -import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent -import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent import com.swmansion.enriched.markdown.views.BlockSegmentView import com.swmansion.enriched.markdown.views.TableContainerView +import java.util.EnumSet import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -33,12 +36,30 @@ class EnrichedMarkdown attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : FrameLayout(context, attrs, defStyleAttr) { + private enum class DirtyFlag { + RECREATE_SEGMENTS, + FORCE_HEIGHT, + } + private val parser = Parser.shared private val mainHandler = Handler(Looper.getMainLooper()) private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private val mathContainerClass: Class<*>? by lazy { + try { + Class.forName("com.swmansion.enriched.markdown.views.MathContainerView") + } catch (_: Exception) { + null + } + } private var currentRenderId = 0L private val segmentViews = mutableListOf() + private val segmentSignatures = mutableListOf() + private val dirtyFlags = EnumSet.noneOf(DirtyFlag::class.java) + var streamingAnimation: Boolean = false + + var tableStreamingMode: TableStreamingMode = TableStreamingMode.HIDDEN + private var renderPending: Boolean = false var currentMarkdown: String = "" private set @@ -75,7 +96,7 @@ class EnrichedMarkdown fun setMarkdownContent(markdown: String) { if (currentMarkdown == markdown) return currentMarkdown = markdown - scheduleRender() + renderPending = true } fun setMarkdownStyle(style: ReadableMap?) { @@ -83,7 +104,17 @@ class EnrichedMarkdown val newConfig = style?.let { StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier) } if (markdownStyle == newConfig) return markdownStyle = newConfig - scheduleRender() + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true + } + + fun commitProps() { + MeasurementStore.updateStreamingTableMode(id, tableStreamingMode) + if (renderPending) { + renderPending = false + scheduleRenderIfNeeded() + } } override fun onConfigurationChanged(newConfig: Configuration) { @@ -93,6 +124,8 @@ class EnrichedMarkdown if (newFontScale != lastKnownFontScale) { lastKnownFontScale = newFontScale recreateStyleConfig() + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT scheduleRenderIfNeeded() } } @@ -100,27 +133,33 @@ class EnrichedMarkdown fun setMd4cFlags(flags: Md4cFlags) { if (md4cFlags == flags) return md4cFlags = flags - scheduleRenderIfNeeded() + renderPending = true } fun setAllowFontScaling(allow: Boolean) { if (allowFontScaling == allow) return allowFontScaling = allow recreateStyleConfig() - scheduleRenderIfNeeded() + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true } fun setMaxFontSizeMultiplier(multiplier: Float) { if (maxFontSizeMultiplier == multiplier) return maxFontSizeMultiplier = multiplier recreateStyleConfig() - scheduleRenderIfNeeded() + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true } fun setAllowTrailingMargin(allow: Boolean) { if (allowTrailingMargin == allow) return allowTrailingMargin = allow - scheduleRenderIfNeeded() + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true } fun setIsSelectable(value: Boolean) { @@ -190,14 +229,28 @@ class EnrichedMarkdown private fun scheduleRender() { val style = markdownStyle ?: return val markdown = currentMarkdown.takeIf { it.isNotEmpty() } ?: return + val isStreaming = streamingAnimation + val tableMode = tableStreamingMode val renderId = ++currentRenderId executor.execute { try { + val renderableMarkdown = + if (isStreaming) { + StreamingMarkdownFilter.renderableMarkdownForStreaming(markdown, tableMode) + } else { + markdown + } + + if (renderableMarkdown.isEmpty()) { + postToMain(renderId) { applyRenderedSegments(emptyList(), style) } + return@execute + } + val ast = - parser.parseMarkdown(markdown, md4cFlags) ?: run { - postToMain(renderId) { clearSegments() } + parser.parseMarkdown(renderableMarkdown, md4cFlags) ?: run { + postToMain(renderId) { applyRenderedSegments(emptyList(), style) } return@execute } @@ -214,7 +267,7 @@ class EnrichedMarkdown postToMain(renderId) { applyRenderedSegments(renderedSegments, style) } } catch (e: Exception) { Log.e(TAG, "Render failed", e) - postToMain(renderId) { clearSegments() } + postToMain(renderId) { applyRenderedSegments(emptyList(), style) } } } } @@ -223,18 +276,130 @@ class EnrichedMarkdown renderedSegments: List, style: StyleConfig, ) { - clearSegments() - renderedSegments.forEach { segment -> - val view = - when (segment) { - is RenderedSegment.Text -> createTextView(segment) - is RenderedSegment.Table -> createTableView(segment, style) - is RenderedSegment.Math -> createMathView(segment, style) + val reset = DirtyFlag.RECREATE_SEGMENTS in dirtyFlags + val forceHeight = DirtyFlag.FORCE_HEIGHT in dirtyFlags + dirtyFlags.clear() + + val result = + SegmentReconciler.reconcile( + currentViews = segmentViews.toList(), + currentSignatures = segmentSignatures.toList(), + renderedSegments = renderedSegments, + reset = reset, + matchesKind = ::viewMatchesSegmentKind, + createView = { segment -> + val view = createSegmentView(segment, style) + animateNewView(view, segment) + view + }, + updateView = { view, segment -> updateSegmentView(view, segment) }, + ) + + result.viewsToRemove.forEach { removeView(it) } + result.viewsToAttach.forEach { addView(it) } + + segmentViews.clear() + segmentViews.addAll(result.views) + segmentSignatures.clear() + segmentSignatures.addAll(result.signatures) + + val topologyChanged = result.viewsToAttach.isNotEmpty() || result.viewsToRemove.isNotEmpty() + + if (width > 0) { + val heightBefore = computeSegmentsTotalHeight() + layoutSegments() + val heightAfter = computeSegmentsTotalHeight() + + if (forceHeight || topologyChanged || heightBefore != heightAfter) { + MeasurementStore.invalidate(id) + requestLayout() + } + } + } + + private fun viewMatchesSegmentKind( + view: View, + segment: RenderedSegment, + ): Boolean = + when (segment) { + is RenderedSegment.Text -> view is EnrichedMarkdownInternalText + is RenderedSegment.Table -> view is TableContainerView + is RenderedSegment.Math -> isMathContainerView(view) + } + + private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true + + private fun createSegmentView( + segment: RenderedSegment, + style: StyleConfig, + ): View = + when (segment) { + is RenderedSegment.Text -> createTextView(segment) + is RenderedSegment.Table -> createTableView(segment, style) + is RenderedSegment.Math -> createMathView(segment, style) + } + + private fun updateSegmentView( + view: View, + segment: RenderedSegment, + ) { + when (segment) { + is RenderedSegment.Text -> { + val textView = view as EnrichedMarkdownInternalText + val tailStart = textView.text?.length ?: 0 + textView.lastElementMarginBottom = segment.lastElementMarginBottom + textView.applyStyledText(segment.styledText) + segment.imageSpans.forEach { it.registerTextView(textView) } + animateTextViewTail(textView, tailStart) + } + + is RenderedSegment.Table -> { + val tableView = view as TableContainerView + val previousRowCount = tableView.rowCount + tableView.applyTableNode(segment.node) + if (streamingAnimation) { + tableView.animateNewRows(previousRowCount, BLOCK_FADE_DURATION_MS) } - segmentViews.add(view) - addView(view) + } + + is RenderedSegment.Math -> { + mathContainerClass + ?.getMethod("applyLatex", String::class.java) + ?.invoke(view, segment.latex) + } } - layoutSegments() + } + + private fun animateNewView( + view: View, + segment: RenderedSegment, + ) { + if (!streamingAnimation) return + when (segment) { + is RenderedSegment.Text -> animateTextViewTail(view as EnrichedMarkdownInternalText, 0) + is RenderedSegment.Table, is RenderedSegment.Math -> animateBlockViewFadeIn(view) + } + } + + private fun animateTextViewTail( + view: EnrichedMarkdownInternalText, + tailStart: Int, + ) { + if (!streamingAnimation) return + val textLength = view.text?.length ?: 0 + if (textLength <= tailStart) return + val animator = TailFadeInAnimator(view) + animator.animate(tailStart, textLength) + } + + private fun animateBlockViewFadeIn(view: View) { + if (!streamingAnimation) return + view.alpha = 0f + view + .animate() + .alpha(1f) + .setDuration(BLOCK_FADE_DURATION_MS) + .start() } private fun createTextView(segment: RenderedSegment.Text) = @@ -273,18 +438,18 @@ class EnrichedMarkdown private fun createMathView( segment: RenderedSegment.Math, style: StyleConfig, - ): android.view.View { - if (!FeatureFlags.IS_MATH_ENABLED) return android.view.View(context) + ): View { + val resolvedClass = mathContainerClass + if (!FeatureFlags.IS_MATH_ENABLED || resolvedClass == null) return View(context) return try { - val mathContainerClass = Class.forName("com.swmansion.enriched.markdown.views.MathContainerView") val view = - mathContainerClass - .getConstructor(android.content.Context::class.java, StyleConfig::class.java) - .newInstance(context, style) as android.view.View - mathContainerClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex) + resolvedClass + .getConstructor(Context::class.java, StyleConfig::class.java) + .newInstance(context, style) as View + resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex) view } catch (_: Exception) { - android.view.View(context) + View(context) } } @@ -297,11 +462,6 @@ class EnrichedMarkdown } } - private fun clearSegments() { - segmentViews.forEach { removeView(it) } - segmentViews.clear() - } - override fun onLayout( changed: Boolean, l: Int, @@ -337,7 +497,26 @@ class EnrichedMarkdown } } + private fun computeSegmentsTotalHeight(): Int { + var totalHeight = 0 + val lastIndex = segmentViews.lastIndex + segmentViews.forEachIndexed { index, view -> + val segment = view as? BlockSegmentView + totalHeight += segment?.segmentMarginTop ?: 0 + totalHeight += view.measuredHeight + if (index != lastIndex || allowTrailingMargin) { + totalHeight += segment?.segmentMarginBottom ?: 0 + } + } + return totalHeight + } + + fun cleanup() { + executor.shutdownNow() + } + companion object { private const val TAG = "EnrichedMarkdown" + private const val BLOCK_FADE_DURATION_MS = 200L } } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index 2684a129..fdb71143 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -12,6 +12,7 @@ import com.facebook.react.viewmanagers.EnrichedMarkdownManagerDelegate import com.facebook.react.viewmanagers.EnrichedMarkdownManagerInterface import com.facebook.yoga.YogaMeasureMode import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay +import com.swmansion.enriched.markdown.utils.common.TableStreamingMode import com.swmansion.enriched.markdown.utils.common.emitContextMenuItemPress import com.swmansion.enriched.markdown.utils.common.emitLinkLongPress import com.swmansion.enriched.markdown.utils.common.emitLinkPress @@ -39,6 +40,18 @@ class EnrichedMarkdownManager : return view } + override fun onAfterUpdateTransaction(view: EnrichedMarkdown) { + super.onAfterUpdateTransaction(view) + view.commitProps() + } + + override fun onDropViewInstance(view: EnrichedMarkdown) { + super.onDropViewInstance(view) + view.cleanup() + MeasurementStore.release(view.id) + MeasurementStore.clearStreamingTableMode(view.id) + } + override fun getExportedCustomDirectEventTypeConstants(): MutableMap = markdownEventTypeConstants() @ReactProp(name = "markdown") @@ -139,8 +152,21 @@ class EnrichedMarkdownManager : view: EnrichedMarkdown?, streamingAnimation: Boolean, ) { - // TODO: Add streaming animation support for github flavor. - // Currently only supported with flavor="commonmark" (single TextView). + view?.streamingAnimation = streamingAnimation + } + + @ReactProp(name = "streamingConfig") + override fun setStreamingConfig( + view: EnrichedMarkdown?, + config: ReadableMap?, + ) { + if (view == null) return + val tableMode = + when (config?.getString("tableMode")) { + "progressive" -> TableStreamingMode.PROGRESSIVE + else -> TableStreamingMode.HIDDEN + } + view.tableStreamingMode = tableMode } @ReactProp(name = "spoilerOverlay") diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt index ed860bc8..3da83143 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt @@ -162,6 +162,14 @@ class EnrichedMarkdownTextManager : view?.setStreamingAnimation(streamingAnimation) } + @ReactProp(name = "streamingConfig") + override fun setStreamingConfig( + view: EnrichedMarkdownText?, + config: ReadableMap?, + ) { + // No-op — CommonMark mode uses a single text view; table streaming is GFM-only. + } + @ReactProp(name = "spoilerOverlay") override fun setSpoilerOverlay( view: EnrichedMarkdownText?, diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt index 0abbba98..52827de8 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -22,6 +22,8 @@ import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.common.FeatureFlags import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer import com.swmansion.enriched.markdown.utils.common.RenderedSegment +import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter +import com.swmansion.enriched.markdown.utils.common.TableStreamingMode import com.swmansion.enriched.markdown.utils.common.getBooleanOrDefault import com.swmansion.enriched.markdown.utils.common.getMapOrNull import com.swmansion.enriched.markdown.utils.common.getStringOrDefault @@ -61,6 +63,8 @@ object MeasurementStore { private val fontScalingSettings = ConcurrentHashMap() + private val streamingTableModes = ConcurrentHashMap() + private fun resolveFontScalingSettings( viewId: Int?, props: ReadableMap?, @@ -105,6 +109,10 @@ object MeasurementStore { data.remove(id) } + fun invalidate(id: Int) { + data.remove(id) + } + /** Main entry point for ShadowNode measurement. */ fun getMeasureById( context: Context, @@ -148,6 +156,17 @@ object MeasurementStore { fontScalingSettings.remove(viewId) } + fun updateStreamingTableMode( + viewId: Int, + mode: TableStreamingMode, + ) { + streamingTableModes[viewId] = mode + } + + fun clearStreamingTableMode(viewId: Int) { + streamingTableModes.remove(viewId) + } + private fun getMeasureByIdInternal( context: Context, id: Int?, @@ -190,6 +209,16 @@ object MeasurementStore { maxFontSizeMultiplier: Float, ): Int { val markdown = props.getStringOrDefault("markdown", "") + return computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + } + + private fun computePropsHashForMarkdown( + markdown: String, + props: ReadableMap?, + allowFontScaling: Boolean, + fontScale: Float, + maxFontSizeMultiplier: Float, + ): Int { val styleMap = props.getMapOrNull("markdownStyle") val md4cFlagsMap = props.getMapOrNull("md4cFlags") val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) @@ -286,7 +315,27 @@ object MeasurementStore { fontScale: Float, maxFontSizeMultiplier: Float, ): Long { - val markdown = props.getStringOrDefault("markdown", "") + val isStreaming = props.getBooleanOrDefault("streamingAnimation", false) + + val rawMarkdown = props.getStringOrDefault("markdown", "") + val tableMode = if (isStreaming) id?.let { streamingTableModes[it] } ?: TableStreamingMode.HIDDEN else TableStreamingMode.HIDDEN + val markdown = + if (isStreaming) { + StreamingMarkdownFilter.renderableMarkdownForStreaming(rawMarkdown, tableMode) + } else { + rawMarkdown + } + val propsHash = computePropsHashForMarkdown(markdown, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + + // Streaming shortcut: reuse cached size when the filtered content and + // width are unchanged. When the filter output changes (e.g. a table + // becomes complete), the hash differs and we fall through to full measure. + if (isStreaming && id != null) { + val cached = data[id] + if (cached != null && cached.cachedWidth == width && cached.markdownHash == propsHash) { + return cached.cachedSize + } + } val styleMap = props.getMapOrNull("markdownStyle") ?: return YogaMeasureOutput.make(PixelUtil.toDIPFromPixel(width), 0f) @@ -297,7 +346,6 @@ object MeasurementStore { latexMath = FeatureFlags.IS_MATH_ENABLED && props.getMapOrNull("md4cFlags").getBooleanOrDefault("latexMath", true), ) val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) - val propsHash = computePropsHash(props, allowFontScaling, fontScale, maxFontSizeMultiplier) val fontSize = getInitialFontSize(styleMap, context, allowFontScaling, fontScale, maxFontSizeMultiplier) return try { diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt index 0120d3f4..f7cabe60 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt @@ -8,19 +8,24 @@ import com.swmansion.enriched.markdown.spans.ImageSpan import com.swmansion.enriched.markdown.styles.StyleConfig sealed interface RenderedSegment { + val signature: Long + data class Text( val styledText: SpannableString, val imageSpans: List, val needsJustify: Boolean, val lastElementMarginBottom: Float, + override val signature: Long, ) : RenderedSegment data class Table( val node: MarkdownASTNode, + override val signature: Long, ) : RenderedSegment data class Math( val latex: String, + override val signature: Long, ) : RenderedSegment } @@ -34,9 +39,20 @@ object MarkdownSegmentRenderer { ): List = segments.map { segment -> when (segment) { - is MarkdownSegment.Text -> renderTextSegment(segment.nodes, style, context, onLinkPress, onLinkLongPress) - is MarkdownSegment.Table -> RenderedSegment.Table(segment.node) - is MarkdownSegment.Math -> RenderedSegment.Math(segment.latex) + is MarkdownSegment.Text -> { + renderTextSegment(segment.nodes, style, context, onLinkPress, onLinkLongPress) + } + + is MarkdownSegment.Table -> { + val signature = SegmentSignature.signatureForNode(segment.node) xor SegmentSignature.TABLE_KIND_SALT + RenderedSegment.Table(segment.node, signature) + } + + is MarkdownSegment.Math -> { + var signature = SegmentSignature.signatureForNode(null) xor SegmentSignature.MATH_KIND_SALT + signature = SegmentSignature.fnvMixString(signature, segment.latex) + RenderedSegment.Math(segment.latex, signature) + } } } @@ -49,12 +65,14 @@ object MarkdownSegmentRenderer { ): RenderedSegment.Text { val documentWrapper = MarkdownASTNode(type = MarkdownASTNode.NodeType.Document, children = nodes) val renderer = Renderer().apply { configure(style, context) } + val signature = SegmentSignature.signatureForNodes(nodes) xor SegmentSignature.TEXT_KIND_SALT return RenderedSegment.Text( styledText = renderer.renderDocument(documentWrapper, onLinkPress, onLinkLongPress), imageSpans = renderer.getCollectedImageSpans().toList(), needsJustify = style.needsJustify, lastElementMarginBottom = renderer.getLastElementMarginBottom(), + signature = signature, ) } } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentReconciler.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentReconciler.kt new file mode 100644 index 00000000..69aaf329 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentReconciler.kt @@ -0,0 +1,118 @@ +package com.swmansion.enriched.markdown.utils.common + +import android.view.View + +data class ReconciliationResult( + val views: List, + val signatures: List, + val viewsToRemove: List, + val viewsToAttach: List, +) + +object SegmentReconciler { + fun reconcile( + currentViews: List, + currentSignatures: List, + renderedSegments: List, + reset: Boolean, + matchesKind: (View, RenderedSegment) -> Boolean, + createView: (RenderedSegment) -> View, + updateView: (View, RenderedSegment) -> Unit, + ): ReconciliationResult { + val resetRemovals = if (reset) currentViews else emptyList() + val sourceViews = if (reset) emptyList() else currentViews + val sourceSignatures = if (reset) emptyList() else currentSignatures + + val signatureToIndices = HashMap>(sourceSignatures.size) + for ((index, signature) in sourceSignatures.withIndex()) { + signatureToIndices.getOrPut(signature) { ArrayDeque() }.addLast(index) + } + + val remainingNextSignatureCounts = HashMap(renderedSegments.size) + for (segment in renderedSegments) { + val signature = segment.signature + remainingNextSignatureCounts[signature] = (remainingNextSignatureCounts[signature] ?: 0) + 1 + } + + val nextViews = ArrayList(renderedSegments.size) + val nextSignatures = ArrayList(renderedSegments.size) + val reusedViews = HashSet(sourceViews.size) + val viewsToAttach = mutableListOf() + + for ((index, segment) in renderedSegments.withIndex()) { + val existingView = sourceViews.getOrNull(index) + val existingSignature = sourceSignatures.getOrNull(index) + val nextSignature = segment.signature + + val remaining = remainingNextSignatureCounts.getOrDefault(nextSignature, 0) + if (remaining > 1) { + remainingNextSignatureCounts[nextSignature] = remaining - 1 + } else { + remainingNextSignatureCounts.remove(nextSignature) + } + + var view: View? = null + + // 1. Exact positional match: same index, same kind, same signature. + if (existingView != null && + existingView !in reusedViews && + matchesKind(existingView, segment) && + existingSignature == nextSignature + ) { + view = existingView + } + + // 2. Signature-based fallback: find an unused view with exact same signature. + if (view == null) { + val candidateIndices = signatureToIndices[nextSignature] + if (candidateIndices != null) { + while (candidateIndices.isNotEmpty()) { + val candidateIdx = candidateIndices.removeFirst() + val candidate = sourceViews[candidateIdx] + if (candidate !in reusedViews && matchesKind(candidate, segment)) { + view = candidate + break + } + } + } + } + + // 3. Same-kind positional update. If the old signature appears later in + // the new list, leave the view available for that exact reuse instead. + if (view == null && + existingView != null && + existingView !in reusedViews && + matchesKind(existingView, segment) && + (existingSignature == null || (remainingNextSignatureCounts[existingSignature] ?: 0) == 0) + ) { + updateView(existingView, segment) + view = existingView + } + + // 4. No reusable view found — create a new one. + if (view == null) { + view = createView(segment) + viewsToAttach.add(view) + } + + nextViews.add(view) + nextSignatures.add(nextSignature) + reusedViews.add(view) + } + + val viewsToRemove = ArrayList(resetRemovals.size + sourceViews.size) + viewsToRemove.addAll(resetRemovals) + for (view in sourceViews) { + if (view !in reusedViews) { + viewsToRemove.add(view) + } + } + + return ReconciliationResult( + views = nextViews, + signatures = nextSignatures, + viewsToRemove = viewsToRemove, + viewsToAttach = viewsToAttach, + ) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt new file mode 100644 index 00000000..40911fe3 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt @@ -0,0 +1,78 @@ +package com.swmansion.enriched.markdown.utils.common + +import com.swmansion.enriched.markdown.parser.MarkdownASTNode + +/** FNV-1a 64-bit hashing. Constants match the iOS implementation for cross-platform parity. */ +object SegmentSignature { + // FNV-1a 64-bit constants (same as iOS) + private const val FNV_OFFSET_BASIS = -3750763034362895579L // 14695981039346656037 as signed Long + private const val FNV_PRIME = 1099511628211L + + internal const val TEXT_KIND_SALT = 0x7465787400000000L // "text" + internal const val TABLE_KIND_SALT = 0x7461626C00000000L // "tabl" + internal const val MATH_KIND_SALT = 0x6D61746800000000L // "math" + + private fun fnvMixByte( + hash: Long, + byte: Byte, + ): Long { + var result = hash xor (byte.toLong() and 0xFF) + result *= FNV_PRIME + return result + } + + private fun fnvMixLong( + hash: Long, + value: Long, + ): Long { + var result = hash + var remaining = value + for (i in 0 until 8) { + result = fnvMixByte(result, (remaining and 0xFF).toByte()) + remaining = remaining ushr 8 + } + return result + } + + internal fun fnvMixString( + hash: Long, + string: String?, + ): Long { + if (string == null) return hash + var result = hash + val bytes = string.toByteArray(Charsets.UTF_8) + for (byte in bytes) { + result = fnvMixByte(result, byte) + } + return result + } + + fun signatureForNode(node: MarkdownASTNode?): Long { + if (node == null) return FNV_OFFSET_BASIS + + var hash = FNV_OFFSET_BASIS + hash = fnvMixLong(hash, node.type.ordinal.toLong()) + hash = fnvMixString(hash, node.content) + + if (node.attributes.isNotEmpty()) { + for (key in node.attributes.keys.sorted()) { + hash = fnvMixString(hash, key) + hash = fnvMixString(hash, node.attributes[key]) + } + } + + for (child in node.children) { + hash = fnvMixLong(hash, signatureForNode(child)) + } + + return hash + } + + fun signatureForNodes(nodes: List): Long { + var hash = FNV_OFFSET_BASIS + for (node in nodes) { + hash = fnvMixLong(hash, signatureForNode(node)) + } + return hash + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/StreamingMarkdownFilter.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/StreamingMarkdownFilter.kt new file mode 100644 index 00000000..89b48411 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/StreamingMarkdownFilter.kt @@ -0,0 +1,142 @@ +package com.swmansion.enriched.markdown.utils.common + +enum class TableStreamingMode { + HIDDEN, + PROGRESSIVE, +} + +/** + * Pre-parse filter that hides incomplete trailing tables and block math + * during streaming. A table is considered complete only after a blank + * separator line follows it; a block math (`$$`) is complete only when + * a closing `$$` exists. + */ +object StreamingMarkdownFilter { + fun renderableMarkdownForStreaming( + markdown: String, + tableMode: TableStreamingMode = TableStreamingMode.HIDDEN, + ): String { + val lines = markdown.split("\n") + val afterMath = removePendingStreamingMathBlock(markdown, lines) + val linesForTable = if (afterMath.length == markdown.length) lines else afterMath.split("\n") + return removePendingStreamingTableBlock(afterMath, linesForTable, tableMode) + } + + private fun removePendingStreamingMathBlock( + markdown: String, + lines: List, + ): String { + var lastUnclosedDelimiterIndex = -1 + + for (i in lines.indices) { + if (lineIsBlockMathDelimiter(lines[i])) { + lastUnclosedDelimiterIndex = if (lastUnclosedDelimiterIndex == -1) i else -1 + } + } + + if (lastUnclosedDelimiterIndex == -1) return markdown + + val offsets = buildLineOffsets(lines) + return markdown.substring(0, offsets[lastUnclosedDelimiterIndex]) + } + + private fun removePendingStreamingTableBlock( + markdown: String, + lines: List, + tableMode: TableStreamingMode, + ): String { + var lastNonBlankLineIndex = -1 + + for (i in lines.indices.reversed()) { + if (!lineIsBlank(lines[i])) { + lastNonBlankLineIndex = i + break + } + } + + if (lastNonBlankLineIndex == -1) return markdown + + if (lastNonBlankLineIndex + 1 < lines.size - 1) return markdown + + var blockStartIndex = lastNonBlankLineIndex + while (blockStartIndex > 0 && !lineIsBlank(lines[blockStartIndex - 1])) { + blockStartIndex-- + } + + var blockLooksLikeTable = false + for (i in blockStartIndex..lastNonBlankLineIndex) { + if (!lineLooksLikeTableRow(lines[i])) return markdown + blockLooksLikeTable = true + } + + if (!blockLooksLikeTable) return markdown + + val offsets = buildLineOffsets(lines) + + if (tableMode == TableStreamingMode.PROGRESSIVE) { + val tableLineCount = lastNonBlankLineIndex - blockStartIndex + 1 + + if (tableLineCount < 2 || !lineLooksLikeTableSeparator(lines[blockStartIndex + 1])) { + return markdown.substring(0, offsets[blockStartIndex]) + } + + if (tableLineCount > 2) { + val lastRow = lines[lastNonBlankLineIndex] + val lastRowTrimmed = lastRow.trim() + val headerRow = lines[blockStartIndex] + if (!lastRowTrimmed.endsWith("|") || pipeCount(lastRow) < pipeCount(headerRow)) { + return markdown.substring(0, offsets[lastNonBlankLineIndex]) + } + } + + return markdown + } + + return markdown.substring(0, offsets[blockStartIndex]) + } + + private fun lineIsBlank(line: String): Boolean = line.isBlank() + + private fun lineIsBlockMathDelimiter(line: String): Boolean = line.trim() == "$$" + + private fun lineLooksLikeTableRow(line: String): Boolean { + val trimmed = line.trim() + return trimmed.startsWith("|") + } + + private fun lineLooksLikeTableSeparator(line: String): Boolean { + val trimmed = line.trim() + if (trimmed.isEmpty()) return false + if (trimmed[0] != '|') return false + var hasTripleDash = false + var dashRun = 0 + for (ch in trimmed) { + if (ch == '-') { + dashRun++ + if (dashRun >= 3) hasTripleDash = true + } else { + dashRun = 0 + if (ch != '|' && ch != ':' && ch != ' ') return false + } + } + return hasTripleDash + } + + private fun pipeCount(line: String): Int { + var count = 0 + for (ch in line) { + if (ch == '|') count++ + } + return count + } + + private fun buildLineOffsets(lines: List): IntArray { + val offsets = IntArray(lines.size) + var currentOffset = 0 + for (i in lines.indices) { + offsets[i] = currentOffset + currentOffset += lines[i].length + 1 + } + return offsets + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt b/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt index a9c6f6d2..8ce6f79a 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt @@ -58,6 +58,32 @@ class TableContainerView( } private val gridContainer get() = scrollView.getChildAt(0) as GridContainerView + val rowCount: Int get() = rows.size + + fun animateNewRows( + previousRowCount: Int, + durationMs: Long, + ) { + if (rowCount <= previousRowCount) return + val grid = gridContainer + val childCount = grid.childCount + if (childCount == 0 || rowCount == 0) return + + val colCount = childCount / rowCount + if (colCount == 0) return + + val firstNewCellIndex = previousRowCount * colCount + for (i in firstNewCellIndex until childCount) { + val cell = grid.getChildAt(i) ?: continue + cell.alpha = 0f + cell + .animate() + .alpha(1f) + .setDuration(durationMs) + .start() + } + } + private var rows: List> = emptyList() private var columnCount = 0 private var columnWidths = emptyList() diff --git a/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h b/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h index f74b7abd..3cdb2f0f 100644 --- a/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +++ b/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h @@ -13,6 +13,7 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownTextProps &props) { serializedProps["markdownStyle"] = toDynamic(props.markdownStyle); serializedProps["md4cFlags"] = toDynamic(props.md4cFlags); serializedProps["allowTrailingMargin"] = props.allowTrailingMargin; + serializedProps["streamingAnimation"] = props.streamingAnimation; return serializedProps; } @@ -23,6 +24,7 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownProps &props) { serializedProps["markdownStyle"] = toDynamic(props.markdownStyle); serializedProps["md4cFlags"] = toDynamic(props.md4cFlags); serializedProps["allowTrailingMargin"] = props.allowTrailingMargin; + serializedProps["streamingAnimation"] = props.streamingAnimation; return serializedProps; } diff --git a/ios/EnrichedMarkdown.mm b/ios/EnrichedMarkdown.mm index 57e37feb..9247b1de 100644 --- a/ios/EnrichedMarkdown.mm +++ b/ios/EnrichedMarkdown.mm @@ -566,32 +566,9 @@ - (void)updateTableView:(TableContainerView *)view withSegment:(ENRMTableSegment NSUInteger previousRowCount = view.rowCount; [view applyTableNode:tableSegment.tableNode]; -#if !TARGET_OS_OSX - if (!_streamingAnimation || view.rowCount <= previousRowCount) { - return; - } - - // The grid container is inside the scroll view: TableContainerView > UIScrollView > gridContainer. - // After applyTableNode:, cells are laid out sequentially — each row has colCount cell-background subviews. - RCTUIView *scrollView = view.subviews.firstObject; - RCTUIView *gridContainer = scrollView.subviews.firstObject; - if (!gridContainer || gridContainer.subviews.count == 0 || view.rowCount == 0) { - return; + if (_streamingAnimation) { + [view animateNewRowsFromPreviousCount:previousRowCount duration:0.20]; } - - NSUInteger colCount = gridContainer.subviews.count / view.rowCount; - if (colCount == 0) { - return; - } - - NSUInteger firstNewCellIndex = previousRowCount * colCount; - NSArray *subviews = gridContainer.subviews; - for (NSUInteger i = firstNewCellIndex; i < subviews.count; i++) { - RCTUIView *cellView = subviews[i]; - cellView.alpha = 0.0; - [UIView animateWithDuration:0.20 animations:^{ cellView.alpha = 1.0; }]; - } -#endif } #if ENRICHED_MARKDOWN_MATH diff --git a/ios/utils/StreamingMarkdownFilter.m b/ios/utils/StreamingMarkdownFilter.m index 1f1b0025..79369d7f 100644 --- a/ios/utils/StreamingMarkdownFilter.m +++ b/ios/utils/StreamingMarkdownFilter.m @@ -13,7 +13,7 @@ static BOOL ENRMLineIsBlockMathDelimiter(NSString *line) static BOOL ENRMLineLooksLikeTableRow(NSString *line) { NSString *trimmed = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; - return [trimmed hasPrefix:@"|"] && [trimmed containsString:@"|"]; + return [trimmed hasPrefix:@"|"]; } static NSUInteger ENRMPipeCount(NSString *line) @@ -55,19 +55,19 @@ static BOOL ENRMLineLooksLikeTableSeparator(NSString *line) return hasTripleDash; } -static NSUInteger ENRMLineStartOffset(NSArray *lines, NSUInteger lineIndex) +static NSUInteger *ENRMBuildLineOffsets(NSArray *lines, NSUInteger count) { - NSUInteger offset = 0; - for (NSUInteger i = 0; i < lineIndex; i++) { - offset += lines[i].length; - offset += 1; + NSUInteger *offsets = (NSUInteger *)calloc(count, sizeof(NSUInteger)); + NSUInteger currentOffset = 0; + for (NSUInteger i = 0; i < count; i++) { + offsets[i] = currentOffset; + currentOffset += lines[i].length + 1; } - return offset; + return offsets; } -static NSString *ENRMRemovePendingStreamingMathBlock(NSString *markdown) +static NSString *ENRMRemovePendingStreamingMathBlock(NSString *markdown, NSArray *lines) { - NSArray *lines = [markdown componentsSeparatedByString:@"\n"]; NSInteger lastUnclosedDelimiterIndex = -1; for (NSUInteger i = 0; i < lines.count; i++) { @@ -80,13 +80,15 @@ static NSUInteger ENRMLineStartOffset(NSArray *lines, NSUInteger lin return markdown; } - NSUInteger offset = ENRMLineStartOffset(lines, (NSUInteger)lastUnclosedDelimiterIndex); - return [markdown substringToIndex:offset]; + NSUInteger *offsets = ENRMBuildLineOffsets(lines, lines.count); + NSString *result = [markdown substringToIndex:offsets[(NSUInteger)lastUnclosedDelimiterIndex]]; + free(offsets); + return result; } -static NSString *ENRMRemovePendingStreamingTableBlock(NSString *markdown, ENRMTableStreamingMode tableMode) +static NSString *ENRMRemovePendingStreamingTableBlock(NSString *markdown, NSArray *lines, + ENRMTableStreamingMode tableMode) { - NSArray *lines = [markdown componentsSeparatedByString:@"\n"]; NSInteger lastNonBlankLineIndex = -1; for (NSInteger i = (NSInteger)lines.count - 1; i >= 0; i--) { @@ -100,8 +102,6 @@ static NSUInteger ENRMLineStartOffset(NSArray *lines, NSUInteger lin return markdown; } - // During streaming, treat a trailing table as complete only after a blank - // separator line. A single trailing newline can still be followed by more rows. if ((NSUInteger)lastNonBlankLineIndex + 1 < lines.count - 1) { return markdown; } @@ -124,36 +124,42 @@ static NSUInteger ENRMLineStartOffset(NSArray *lines, NSUInteger lin return markdown; } + NSUInteger *offsets = ENRMBuildLineOffsets(lines, lines.count); + if (tableMode == ENRMTableStreamingModeProgressive) { NSInteger tableLineCount = lastNonBlankLineIndex - blockStartIndex + 1; - // Need at least header + separator to show anything. if (tableLineCount < 2 || !ENRMLineLooksLikeTableSeparator(lines[(NSUInteger)blockStartIndex + 1])) { - NSUInteger offset = ENRMLineStartOffset(lines, (NSUInteger)blockStartIndex); - return [markdown substringToIndex:offset]; + NSString *result = [markdown substringToIndex:offsets[(NSUInteger)blockStartIndex]]; + free(offsets); + return result; } - // Trim the last data row if it's incomplete: either doesn't end with '|' - // or has fewer pipe characters than the header (mid-cell streaming). if (tableLineCount > 2) { NSString *lastRow = lines[(NSUInteger)lastNonBlankLineIndex]; NSString *lastRowTrimmed = [lastRow stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; NSString *headerRow = lines[(NSUInteger)blockStartIndex]; if (![lastRowTrimmed hasSuffix:@"|"] || ENRMPipeCount(lastRow) < ENRMPipeCount(headerRow)) { - NSUInteger offset = ENRMLineStartOffset(lines, (NSUInteger)lastNonBlankLineIndex); - return [markdown substringToIndex:offset]; + NSString *result = [markdown substringToIndex:offsets[(NSUInteger)lastNonBlankLineIndex]]; + free(offsets); + return result; } } + free(offsets); return markdown; } - NSUInteger offset = ENRMLineStartOffset(lines, (NSUInteger)blockStartIndex); - return [markdown substringToIndex:offset]; + NSString *result = [markdown substringToIndex:offsets[(NSUInteger)blockStartIndex]]; + free(offsets); + return result; } NSString *ENRMRenderableMarkdownForStreaming(NSString *markdown, ENRMTableStreamingMode tableMode) { - NSString *withoutPendingMath = ENRMRemovePendingStreamingMathBlock(markdown); - return ENRMRemovePendingStreamingTableBlock(withoutPendingMath, tableMode); + NSArray *lines = [markdown componentsSeparatedByString:@"\n"]; + NSString *afterMath = ENRMRemovePendingStreamingMathBlock(markdown, lines); + NSArray *linesForTable = + (afterMath.length == markdown.length) ? lines : [afterMath componentsSeparatedByString:@"\n"]; + return ENRMRemovePendingStreamingTableBlock(afterMath, linesForTable, tableMode); } diff --git a/ios/views/TableContainerView.h b/ios/views/TableContainerView.h index 50621404..2669004c 100644 --- a/ios/views/TableContainerView.h +++ b/ios/views/TableContainerView.h @@ -28,6 +28,8 @@ typedef void (^TableLinkPressBlock)(NSString *url); @property (nonatomic, readonly) NSUInteger rowCount; +- (void)animateNewRowsFromPreviousCount:(NSUInteger)previousRowCount duration:(NSTimeInterval)duration; + @end NS_ASSUME_NONNULL_END diff --git a/ios/views/TableContainerView.m b/ios/views/TableContainerView.m index eeba11be..c18b79e2 100644 --- a/ios/views/TableContainerView.m +++ b/ios/views/TableContainerView.m @@ -172,6 +172,38 @@ - (NSUInteger)rowCount return _rows.count; } +#if !TARGET_OS_OSX +- (void)animateNewRowsFromPreviousCount:(NSUInteger)previousRowCount duration:(NSTimeInterval)duration +{ + if (self.rowCount <= previousRowCount) { + return; + } + + NSArray *subviews = _gridContainer.subviews; + NSUInteger childCount = subviews.count; + if (childCount == 0 || self.rowCount == 0) { + return; + } + + NSUInteger colCount = childCount / self.rowCount; + if (colCount == 0) { + return; + } + + NSUInteger firstNewCellIndex = previousRowCount * colCount; + for (NSUInteger i = firstNewCellIndex; i < childCount; i++) { + RCTUIView *cellView = subviews[i]; + cellView.alpha = 0.0; + [UIView animateWithDuration:duration animations:^{ cellView.alpha = 1.0; }]; + } +} +#else +- (void)animateNewRowsFromPreviousCount:(NSUInteger)previousRowCount duration:(NSTimeInterval)duration +{ + // No-op on macOS +} +#endif + - (void)applyTableNode:(MarkdownASTNode *)tableNode { [[_gridContainer subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)];