Skip to content

Commit 98d17a4

Browse files
authored
feat: add GFM streaming support for tables and block math (#270)
* feat(ios): improve GFM streaming for tables and math * feat(ios): improve GFM streaming for tables and math * refactor(ios): change segment signature type from NSString to uint64_t for improved performance * refactor(ios): enhance segment view reconciliation with two-pass matching strategy * docs(ios): add comments to ShadowMeasurementUtils.h explaining measureContent behavior * refactor(ios): move inline functions from StreamingMarkdownFilter.h to StreamingMarkdownFilter.m * refactor(ios): consolidate measurement logic for EnrichedMarkdown components into reusable functions * refactor(ios): replace boolean flags with bitmask for segment rendering state management * refactor(ios): optimize segment view reconciliation by tracking remaining signatures for reuse * feat(ios): add streamingConfig with progressive table mode for GFM streaming * feat(android): add GFM streaming support for tables and block math (#271) * feat(android): add GFM streaming support for tables and block math * feat(android): implement table streaming mode for GFM support in EnrichedMarkdown * refactor(ios): streamline line offset calculations in StreamingMarkdownFilter * docs: update API reference and markdown streaming documentation to include for GFM table handling
1 parent e4741a6 commit 98d17a4

39 files changed

Lines changed: 2277 additions & 359 deletions

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt

Lines changed: 213 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ import com.swmansion.enriched.markdown.styles.StyleConfig
1717
import com.swmansion.enriched.markdown.utils.common.FeatureFlags
1818
import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer
1919
import com.swmansion.enriched.markdown.utils.common.RenderedSegment
20+
import com.swmansion.enriched.markdown.utils.common.SegmentReconciler
21+
import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter
22+
import com.swmansion.enriched.markdown.utils.common.TableStreamingMode
2023
import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments
24+
import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
2125
import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
22-
import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
23-
import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
2426
import com.swmansion.enriched.markdown.views.BlockSegmentView
2527
import com.swmansion.enriched.markdown.views.TableContainerView
28+
import java.util.EnumSet
2629
import java.util.concurrent.ExecutorService
2730
import java.util.concurrent.Executors
2831

@@ -33,12 +36,30 @@ class EnrichedMarkdown
3336
attrs: AttributeSet? = null,
3437
defStyleAttr: Int = 0,
3538
) : FrameLayout(context, attrs, defStyleAttr) {
39+
private enum class DirtyFlag {
40+
RECREATE_SEGMENTS,
41+
FORCE_HEIGHT,
42+
}
43+
3644
private val parser = Parser.shared
3745
private val mainHandler = Handler(Looper.getMainLooper())
3846
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
47+
private val mathContainerClass: Class<*>? by lazy {
48+
try {
49+
Class.forName("com.swmansion.enriched.markdown.views.MathContainerView")
50+
} catch (_: Exception) {
51+
null
52+
}
53+
}
3954

4055
private var currentRenderId = 0L
4156
private val segmentViews = mutableListOf<View>()
57+
private val segmentSignatures = mutableListOf<Long>()
58+
private val dirtyFlags = EnumSet.noneOf(DirtyFlag::class.java)
59+
var streamingAnimation: Boolean = false
60+
61+
var tableStreamingMode: TableStreamingMode = TableStreamingMode.HIDDEN
62+
private var renderPending: Boolean = false
4263

4364
var currentMarkdown: String = ""
4465
private set
@@ -75,15 +96,25 @@ class EnrichedMarkdown
7596
fun setMarkdownContent(markdown: String) {
7697
if (currentMarkdown == markdown) return
7798
currentMarkdown = markdown
78-
scheduleRender()
99+
renderPending = true
79100
}
80101

81102
fun setMarkdownStyle(style: ReadableMap?) {
82103
markdownStyleMap = style
83104
val newConfig = style?.let { StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier) }
84105
if (markdownStyle == newConfig) return
85106
markdownStyle = newConfig
86-
scheduleRender()
107+
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
108+
dirtyFlags += DirtyFlag.FORCE_HEIGHT
109+
renderPending = true
110+
}
111+
112+
fun commitProps() {
113+
MeasurementStore.updateStreamingTableMode(id, tableStreamingMode)
114+
if (renderPending) {
115+
renderPending = false
116+
scheduleRenderIfNeeded()
117+
}
87118
}
88119

89120
override fun onConfigurationChanged(newConfig: Configuration) {
@@ -93,34 +124,42 @@ class EnrichedMarkdown
93124
if (newFontScale != lastKnownFontScale) {
94125
lastKnownFontScale = newFontScale
95126
recreateStyleConfig()
127+
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
128+
dirtyFlags += DirtyFlag.FORCE_HEIGHT
96129
scheduleRenderIfNeeded()
97130
}
98131
}
99132

100133
fun setMd4cFlags(flags: Md4cFlags) {
101134
if (md4cFlags == flags) return
102135
md4cFlags = flags
103-
scheduleRenderIfNeeded()
136+
renderPending = true
104137
}
105138

106139
fun setAllowFontScaling(allow: Boolean) {
107140
if (allowFontScaling == allow) return
108141
allowFontScaling = allow
109142
recreateStyleConfig()
110-
scheduleRenderIfNeeded()
143+
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
144+
dirtyFlags += DirtyFlag.FORCE_HEIGHT
145+
renderPending = true
111146
}
112147

113148
fun setMaxFontSizeMultiplier(multiplier: Float) {
114149
if (maxFontSizeMultiplier == multiplier) return
115150
maxFontSizeMultiplier = multiplier
116151
recreateStyleConfig()
117-
scheduleRenderIfNeeded()
152+
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
153+
dirtyFlags += DirtyFlag.FORCE_HEIGHT
154+
renderPending = true
118155
}
119156

120157
fun setAllowTrailingMargin(allow: Boolean) {
121158
if (allowTrailingMargin == allow) return
122159
allowTrailingMargin = allow
123-
scheduleRenderIfNeeded()
160+
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
161+
dirtyFlags += DirtyFlag.FORCE_HEIGHT
162+
renderPending = true
124163
}
125164

126165
fun setIsSelectable(value: Boolean) {
@@ -190,14 +229,28 @@ class EnrichedMarkdown
190229
private fun scheduleRender() {
191230
val style = markdownStyle ?: return
192231
val markdown = currentMarkdown.takeIf { it.isNotEmpty() } ?: return
232+
val isStreaming = streamingAnimation
233+
val tableMode = tableStreamingMode
193234

194235
val renderId = ++currentRenderId
195236

196237
executor.execute {
197238
try {
239+
val renderableMarkdown =
240+
if (isStreaming) {
241+
StreamingMarkdownFilter.renderableMarkdownForStreaming(markdown, tableMode)
242+
} else {
243+
markdown
244+
}
245+
246+
if (renderableMarkdown.isEmpty()) {
247+
postToMain(renderId) { applyRenderedSegments(emptyList(), style) }
248+
return@execute
249+
}
250+
198251
val ast =
199-
parser.parseMarkdown(markdown, md4cFlags) ?: run {
200-
postToMain(renderId) { clearSegments() }
252+
parser.parseMarkdown(renderableMarkdown, md4cFlags) ?: run {
253+
postToMain(renderId) { applyRenderedSegments(emptyList(), style) }
201254
return@execute
202255
}
203256

@@ -214,7 +267,7 @@ class EnrichedMarkdown
214267
postToMain(renderId) { applyRenderedSegments(renderedSegments, style) }
215268
} catch (e: Exception) {
216269
Log.e(TAG, "Render failed", e)
217-
postToMain(renderId) { clearSegments() }
270+
postToMain(renderId) { applyRenderedSegments(emptyList(), style) }
218271
}
219272
}
220273
}
@@ -223,18 +276,130 @@ class EnrichedMarkdown
223276
renderedSegments: List<RenderedSegment>,
224277
style: StyleConfig,
225278
) {
226-
clearSegments()
227-
renderedSegments.forEach { segment ->
228-
val view =
229-
when (segment) {
230-
is RenderedSegment.Text -> createTextView(segment)
231-
is RenderedSegment.Table -> createTableView(segment, style)
232-
is RenderedSegment.Math -> createMathView(segment, style)
279+
val reset = DirtyFlag.RECREATE_SEGMENTS in dirtyFlags
280+
val forceHeight = DirtyFlag.FORCE_HEIGHT in dirtyFlags
281+
dirtyFlags.clear()
282+
283+
val result =
284+
SegmentReconciler.reconcile(
285+
currentViews = segmentViews.toList(),
286+
currentSignatures = segmentSignatures.toList(),
287+
renderedSegments = renderedSegments,
288+
reset = reset,
289+
matchesKind = ::viewMatchesSegmentKind,
290+
createView = { segment ->
291+
val view = createSegmentView(segment, style)
292+
animateNewView(view, segment)
293+
view
294+
},
295+
updateView = { view, segment -> updateSegmentView(view, segment) },
296+
)
297+
298+
result.viewsToRemove.forEach { removeView(it) }
299+
result.viewsToAttach.forEach { addView(it) }
300+
301+
segmentViews.clear()
302+
segmentViews.addAll(result.views)
303+
segmentSignatures.clear()
304+
segmentSignatures.addAll(result.signatures)
305+
306+
val topologyChanged = result.viewsToAttach.isNotEmpty() || result.viewsToRemove.isNotEmpty()
307+
308+
if (width > 0) {
309+
val heightBefore = computeSegmentsTotalHeight()
310+
layoutSegments()
311+
val heightAfter = computeSegmentsTotalHeight()
312+
313+
if (forceHeight || topologyChanged || heightBefore != heightAfter) {
314+
MeasurementStore.invalidate(id)
315+
requestLayout()
316+
}
317+
}
318+
}
319+
320+
private fun viewMatchesSegmentKind(
321+
view: View,
322+
segment: RenderedSegment,
323+
): Boolean =
324+
when (segment) {
325+
is RenderedSegment.Text -> view is EnrichedMarkdownInternalText
326+
is RenderedSegment.Table -> view is TableContainerView
327+
is RenderedSegment.Math -> isMathContainerView(view)
328+
}
329+
330+
private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true
331+
332+
private fun createSegmentView(
333+
segment: RenderedSegment,
334+
style: StyleConfig,
335+
): View =
336+
when (segment) {
337+
is RenderedSegment.Text -> createTextView(segment)
338+
is RenderedSegment.Table -> createTableView(segment, style)
339+
is RenderedSegment.Math -> createMathView(segment, style)
340+
}
341+
342+
private fun updateSegmentView(
343+
view: View,
344+
segment: RenderedSegment,
345+
) {
346+
when (segment) {
347+
is RenderedSegment.Text -> {
348+
val textView = view as EnrichedMarkdownInternalText
349+
val tailStart = textView.text?.length ?: 0
350+
textView.lastElementMarginBottom = segment.lastElementMarginBottom
351+
textView.applyStyledText(segment.styledText)
352+
segment.imageSpans.forEach { it.registerTextView(textView) }
353+
animateTextViewTail(textView, tailStart)
354+
}
355+
356+
is RenderedSegment.Table -> {
357+
val tableView = view as TableContainerView
358+
val previousRowCount = tableView.rowCount
359+
tableView.applyTableNode(segment.node)
360+
if (streamingAnimation) {
361+
tableView.animateNewRows(previousRowCount, BLOCK_FADE_DURATION_MS)
233362
}
234-
segmentViews.add(view)
235-
addView(view)
363+
}
364+
365+
is RenderedSegment.Math -> {
366+
mathContainerClass
367+
?.getMethod("applyLatex", String::class.java)
368+
?.invoke(view, segment.latex)
369+
}
236370
}
237-
layoutSegments()
371+
}
372+
373+
private fun animateNewView(
374+
view: View,
375+
segment: RenderedSegment,
376+
) {
377+
if (!streamingAnimation) return
378+
when (segment) {
379+
is RenderedSegment.Text -> animateTextViewTail(view as EnrichedMarkdownInternalText, 0)
380+
is RenderedSegment.Table, is RenderedSegment.Math -> animateBlockViewFadeIn(view)
381+
}
382+
}
383+
384+
private fun animateTextViewTail(
385+
view: EnrichedMarkdownInternalText,
386+
tailStart: Int,
387+
) {
388+
if (!streamingAnimation) return
389+
val textLength = view.text?.length ?: 0
390+
if (textLength <= tailStart) return
391+
val animator = TailFadeInAnimator(view)
392+
animator.animate(tailStart, textLength)
393+
}
394+
395+
private fun animateBlockViewFadeIn(view: View) {
396+
if (!streamingAnimation) return
397+
view.alpha = 0f
398+
view
399+
.animate()
400+
.alpha(1f)
401+
.setDuration(BLOCK_FADE_DURATION_MS)
402+
.start()
238403
}
239404

240405
private fun createTextView(segment: RenderedSegment.Text) =
@@ -273,18 +438,18 @@ class EnrichedMarkdown
273438
private fun createMathView(
274439
segment: RenderedSegment.Math,
275440
style: StyleConfig,
276-
): android.view.View {
277-
if (!FeatureFlags.IS_MATH_ENABLED) return android.view.View(context)
441+
): View {
442+
val resolvedClass = mathContainerClass
443+
if (!FeatureFlags.IS_MATH_ENABLED || resolvedClass == null) return View(context)
278444
return try {
279-
val mathContainerClass = Class.forName("com.swmansion.enriched.markdown.views.MathContainerView")
280445
val view =
281-
mathContainerClass
282-
.getConstructor(android.content.Context::class.java, StyleConfig::class.java)
283-
.newInstance(context, style) as android.view.View
284-
mathContainerClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex)
446+
resolvedClass
447+
.getConstructor(Context::class.java, StyleConfig::class.java)
448+
.newInstance(context, style) as View
449+
resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex)
285450
view
286451
} catch (_: Exception) {
287-
android.view.View(context)
452+
View(context)
288453
}
289454
}
290455

@@ -297,11 +462,6 @@ class EnrichedMarkdown
297462
}
298463
}
299464

300-
private fun clearSegments() {
301-
segmentViews.forEach { removeView(it) }
302-
segmentViews.clear()
303-
}
304-
305465
override fun onLayout(
306466
changed: Boolean,
307467
l: Int,
@@ -337,7 +497,26 @@ class EnrichedMarkdown
337497
}
338498
}
339499

500+
private fun computeSegmentsTotalHeight(): Int {
501+
var totalHeight = 0
502+
val lastIndex = segmentViews.lastIndex
503+
segmentViews.forEachIndexed { index, view ->
504+
val segment = view as? BlockSegmentView
505+
totalHeight += segment?.segmentMarginTop ?: 0
506+
totalHeight += view.measuredHeight
507+
if (index != lastIndex || allowTrailingMargin) {
508+
totalHeight += segment?.segmentMarginBottom ?: 0
509+
}
510+
}
511+
return totalHeight
512+
}
513+
514+
fun cleanup() {
515+
executor.shutdownNow()
516+
}
517+
340518
companion object {
341519
private const val TAG = "EnrichedMarkdown"
520+
private const val BLOCK_FADE_DURATION_MS = 200L
342521
}
343522
}

0 commit comments

Comments
 (0)