react-native-jet-markdown 0.2.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/JetMarkdown.podspec +42 -0
  2. package/LICENSE +20 -0
  3. package/README.md +247 -0
  4. package/android/CMakeLists.txt +52 -0
  5. package/android/build.gradle +55 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/JniBindings.cpp +243 -0
  8. package/android/src/main/java/com/jetmarkdown/JetMarkdownNative.kt +114 -0
  9. package/android/src/main/java/com/jetmarkdown/JetMarkdownPackage.kt +22 -0
  10. package/android/src/main/java/com/jetmarkdown/JetMarkdownView.kt +235 -0
  11. package/android/src/main/java/com/jetmarkdown/JetMarkdownViewManager.kt +75 -0
  12. package/android/src/main/java/com/jetmarkdown/editor/EditorSpans.kt +217 -0
  13. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorManager.kt +224 -0
  14. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorView.kt +1736 -0
  15. package/android/src/main/java/com/jetmarkdown/measure/MarkdownMeasurer.kt +54 -0
  16. package/android/src/main/java/com/jetmarkdown/parser/AstDecoder.kt +59 -0
  17. package/android/src/main/java/com/jetmarkdown/parser/MdNode.kt +43 -0
  18. package/android/src/main/java/com/jetmarkdown/render/ContentCache.kt +53 -0
  19. package/android/src/main/java/com/jetmarkdown/render/RenderedContent.kt +318 -0
  20. package/android/src/main/java/com/jetmarkdown/render/SpannableRenderer.kt +658 -0
  21. package/android/src/main/java/com/jetmarkdown/render/spans/ChipSpan.kt +17 -0
  22. package/android/src/main/java/com/jetmarkdown/render/spans/InteractiveSpans.kt +11 -0
  23. package/android/src/main/java/com/jetmarkdown/render/spans/MarkdownLineHeightSpan.kt +59 -0
  24. package/android/src/main/java/com/jetmarkdown/render/spans/RunSpan.kt +40 -0
  25. package/android/src/main/java/com/jetmarkdown/style/Fonts.kt +34 -0
  26. package/android/src/main/java/com/jetmarkdown/style/LayoutStyleSpec.kt +71 -0
  27. package/android/src/main/java/com/jetmarkdown/style/PlatformColorResolver.kt +77 -0
  28. package/android/src/main/java/com/jetmarkdown/style/StyleConfig.kt +80 -0
  29. package/android/src/main/java/com/jetmarkdown/style/TextStyleSpec.kt +61 -0
  30. package/android/src/main/java/com/jetmarkdown/views/BlockStackView.kt +292 -0
  31. package/android/src/main/java/com/jetmarkdown/views/BlockTextView.kt +378 -0
  32. package/android/src/main/java/com/jetmarkdown/views/BoxDrawing.kt +50 -0
  33. package/android/src/main/java/com/jetmarkdown/views/MarkdownHost.kt +12 -0
  34. package/android/src/main/java/com/jetmarkdown/views/MarkdownImageView.kt +113 -0
  35. package/android/src/main/java/com/jetmarkdown/views/NestedHorizontalScrollView.kt +87 -0
  36. package/android/src/main/java/com/jetmarkdown/views/TableBlockView.kt +139 -0
  37. package/cpp/core/Ast.h +85 -0
  38. package/cpp/core/AstJson.cpp +129 -0
  39. package/cpp/core/AstJson.h +12 -0
  40. package/cpp/core/AstSerializer.cpp +40 -0
  41. package/cpp/core/AstSerializer.h +21 -0
  42. package/cpp/core/AstToMarkdown.cpp +549 -0
  43. package/cpp/core/AstToMarkdown.h +16 -0
  44. package/cpp/core/EditorRuns.cpp +640 -0
  45. package/cpp/core/EditorRuns.h +94 -0
  46. package/cpp/core/EditorText.cpp +15 -0
  47. package/cpp/core/EditorText.h +18 -0
  48. package/cpp/core/InlineExtensions.cpp +305 -0
  49. package/cpp/core/InlineExtensions.h +17 -0
  50. package/cpp/core/Parser.cpp +470 -0
  51. package/cpp/core/Parser.h +16 -0
  52. package/cpp/core/Preprocess.cpp +181 -0
  53. package/cpp/core/Preprocess.h +14 -0
  54. package/cpp/md4c/LICENSE +22 -0
  55. package/cpp/md4c/VERSION +1 -0
  56. package/cpp/md4c/md4c.c +6462 -0
  57. package/cpp/md4c/md4c.h +407 -0
  58. package/cpp/react/JetMarkdownEditorShadowNode.cpp +54 -0
  59. package/cpp/react/JetMarkdownEditorShadowNode.h +55 -0
  60. package/cpp/react/JetMarkdownEditorState.h +35 -0
  61. package/cpp/react/JetMarkdownMeasurer.cpp +32 -0
  62. package/cpp/react/JetMarkdownMeasurer.h +41 -0
  63. package/cpp/react/JetMarkdownShadowNode.cpp +100 -0
  64. package/cpp/react/JetMarkdownShadowNode.h +53 -0
  65. package/cpp/react/JetMarkdownState.h +52 -0
  66. package/cpp/react/override/react/renderer/components/JetMarkdownViewSpec/ComponentDescriptors.h +21 -0
  67. package/cpp/tests/parser_tests.cpp +662 -0
  68. package/cpp/tests/run_tests.sh +25 -0
  69. package/ios/JetMarkdownView.h +14 -0
  70. package/ios/JetMarkdownView.mm +468 -0
  71. package/ios/editor/JetMarkdownEditor.h +12 -0
  72. package/ios/editor/JetMarkdownEditor.mm +2002 -0
  73. package/ios/measure/JMDMarkdownMeasurer.h +22 -0
  74. package/ios/measure/JMDMarkdownMeasurer.mm +38 -0
  75. package/ios/render/JMDBlock.h +97 -0
  76. package/ios/render/JMDBlock.m +28 -0
  77. package/ios/render/JMDBlockRenderer.h +17 -0
  78. package/ios/render/JMDBlockRenderer.mm +852 -0
  79. package/ios/render/JMDContentCache.h +17 -0
  80. package/ios/render/JMDContentCache.mm +42 -0
  81. package/ios/render/JMDRenderedContent.h +35 -0
  82. package/ios/render/JMDRenderedContent.mm +274 -0
  83. package/ios/style/JMDFontScale.h +33 -0
  84. package/ios/style/JMDLayoutStyle.h +37 -0
  85. package/ios/style/JMDLayoutStyle.mm +68 -0
  86. package/ios/style/JMDStyleConfig.h +39 -0
  87. package/ios/style/JMDStyleConfig.mm +111 -0
  88. package/ios/style/JMDTextStyle.h +25 -0
  89. package/ios/style/JMDTextStyle.mm +68 -0
  90. package/ios/views/JMDBlockStackView.h +18 -0
  91. package/ios/views/JMDBlockStackView.m +97 -0
  92. package/ios/views/JMDBlockTextView.h +24 -0
  93. package/ios/views/JMDBlockTextView.m +340 -0
  94. package/ios/views/JMDBoxViews.h +33 -0
  95. package/ios/views/JMDBoxViews.m +292 -0
  96. package/ios/views/JMDImageView.h +19 -0
  97. package/ios/views/JMDImageView.m +68 -0
  98. package/ios/views/JMDMarkdownHost.h +16 -0
  99. package/ios/views/JMDTableView.h +16 -0
  100. package/ios/views/JMDTableView.m +172 -0
  101. package/lib/module/JetMarkdownEditor.js +85 -0
  102. package/lib/module/JetMarkdownEditor.js.map +1 -0
  103. package/lib/module/JetMarkdownEditor.native.js +244 -0
  104. package/lib/module/JetMarkdownEditor.native.js.map +1 -0
  105. package/lib/module/JetMarkdownEditorNativeComponent.ts +173 -0
  106. package/lib/module/JetMarkdownView.js +19 -0
  107. package/lib/module/JetMarkdownView.js.map +1 -0
  108. package/lib/module/JetMarkdownView.native.js +44 -0
  109. package/lib/module/JetMarkdownView.native.js.map +1 -0
  110. package/lib/module/JetMarkdownViewNativeComponent.ts +35 -0
  111. package/lib/module/defaultStyles.js +122 -0
  112. package/lib/module/defaultStyles.js.map +1 -0
  113. package/lib/module/index.js +9 -0
  114. package/lib/module/index.js.map +1 -0
  115. package/lib/module/package.json +1 -0
  116. package/lib/module/serializeStyles.js +259 -0
  117. package/lib/module/serializeStyles.js.map +1 -0
  118. package/lib/module/types.js +4 -0
  119. package/lib/module/types.js.map +1 -0
  120. package/lib/module/useJetMarkdownEditor.js +41 -0
  121. package/lib/module/useJetMarkdownEditor.js.map +1 -0
  122. package/lib/typescript/package.json +1 -0
  123. package/lib/typescript/src/JetMarkdownEditor.d.ts +6 -0
  124. package/lib/typescript/src/JetMarkdownEditor.d.ts.map +1 -0
  125. package/lib/typescript/src/JetMarkdownEditor.native.d.ts +6 -0
  126. package/lib/typescript/src/JetMarkdownEditor.native.d.ts.map +1 -0
  127. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts +105 -0
  128. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts.map +1 -0
  129. package/lib/typescript/src/JetMarkdownView.d.ts +3 -0
  130. package/lib/typescript/src/JetMarkdownView.d.ts.map +1 -0
  131. package/lib/typescript/src/JetMarkdownView.native.d.ts +3 -0
  132. package/lib/typescript/src/JetMarkdownView.native.d.ts.map +1 -0
  133. package/lib/typescript/src/JetMarkdownViewNativeComponent.d.ts +28 -0
  134. package/lib/typescript/src/JetMarkdownViewNativeComponent.d.ts.map +1 -0
  135. package/lib/typescript/src/defaultStyles.d.ts +20 -0
  136. package/lib/typescript/src/defaultStyles.d.ts.map +1 -0
  137. package/lib/typescript/src/index.d.ts +7 -0
  138. package/lib/typescript/src/index.d.ts.map +1 -0
  139. package/lib/typescript/src/serializeStyles.d.ts +34 -0
  140. package/lib/typescript/src/serializeStyles.d.ts.map +1 -0
  141. package/lib/typescript/src/types.d.ts +352 -0
  142. package/lib/typescript/src/types.d.ts.map +1 -0
  143. package/lib/typescript/src/useJetMarkdownEditor.d.ts +36 -0
  144. package/lib/typescript/src/useJetMarkdownEditor.d.ts.map +1 -0
  145. package/package.json +150 -0
  146. package/react-native.config.js +11 -0
  147. package/src/JetMarkdownEditor.native.tsx +301 -0
  148. package/src/JetMarkdownEditor.tsx +90 -0
  149. package/src/JetMarkdownEditorNativeComponent.ts +173 -0
  150. package/src/JetMarkdownView.native.tsx +67 -0
  151. package/src/JetMarkdownView.tsx +16 -0
  152. package/src/JetMarkdownViewNativeComponent.ts +35 -0
  153. package/src/defaultStyles.ts +102 -0
  154. package/src/index.tsx +34 -0
  155. package/src/serializeStyles.ts +369 -0
  156. package/src/types.ts +438 -0
  157. package/src/useJetMarkdownEditor.ts +50 -0
@@ -0,0 +1,292 @@
1
+ package com.jetmarkdown.views
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.view.View
6
+ import android.view.ViewGroup
7
+ import com.jetmarkdown.render.Block
8
+ import com.jetmarkdown.render.MeasuredBlock
9
+
10
+ /**
11
+ * Vertical stack of measured blocks. All heights come from the measured
12
+ * tree, so onLayout only distributes frames.
13
+ */
14
+ class BlockStackView(context: Context) : ViewGroup(context) {
15
+ private var measured: List<MeasuredBlock> = emptyList()
16
+ private var gapPx = 0f
17
+
18
+ var host: MarkdownHost? = null
19
+
20
+ private fun unbindImages(group: android.view.ViewGroup) {
21
+ for (index in 0 until group.childCount) {
22
+ when (val child = group.getChildAt(index)) {
23
+ is MarkdownImageView -> child.unbind()
24
+ is android.view.ViewGroup -> unbindImages(child)
25
+ }
26
+ }
27
+ }
28
+
29
+ fun setBlocks(blocks: List<MeasuredBlock>, gap: Float) {
30
+ unbindImages(this)
31
+ measured = blocks
32
+ gapPx = gap
33
+ removeAllViews()
34
+ for (block in blocks) {
35
+ addView(createView(block))
36
+ }
37
+ requestLayout()
38
+ }
39
+
40
+ private fun createView(measuredBlock: MeasuredBlock): View {
41
+ return when (val block = measuredBlock.block) {
42
+ is Block.Text -> BlockTextView(context).apply {
43
+ this.host = this@BlockStackView.host
44
+ setBlock(block)
45
+ measuredBlock.textLayout?.let(::setTextLayout)
46
+ }
47
+ is Block.Code -> CodeBlockView(context).apply { bind(measuredBlock, block) }
48
+ is Block.Quote -> QuoteView(context).apply {
49
+ bind(measuredBlock, block, gapPx, this@BlockStackView.host)
50
+ }
51
+ is Block.ListBlock -> ListBlockView(context).apply {
52
+ bind(measuredBlock, block, gapPx, this@BlockStackView.host)
53
+ }
54
+ is Block.Divider -> DividerView(context).apply { color = block.color }
55
+ is Block.Table -> TableBlockView(context).apply {
56
+ bind(measuredBlock, block, this@BlockStackView.host)
57
+ }
58
+ is Block.Image -> MarkdownImageView(context).apply {
59
+ this.host = this@BlockStackView.host
60
+ bind(block)
61
+ }
62
+ }
63
+ }
64
+
65
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
66
+ setMeasuredDimension(
67
+ MeasureSpec.getSize(widthMeasureSpec),
68
+ MeasureSpec.getSize(heightMeasureSpec),
69
+ )
70
+ }
71
+
72
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
73
+ val width = r - l
74
+ var y = 0
75
+ measured.forEachIndexed { index, block ->
76
+ val child = getChildAt(index) ?: return@forEachIndexed
77
+ val height = block.heightPx.toInt()
78
+ val childWidth = if (block.block is Block.Image) {
79
+ block.contentWidthPx.toInt().coerceAtMost(width)
80
+ } else {
81
+ width
82
+ }
83
+ child.measure(
84
+ MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY),
85
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
86
+ )
87
+ child.layout(0, y, childWidth, y + height)
88
+ y += height
89
+ if (index < measured.size - 1) {
90
+ y += gapPx.toInt()
91
+ }
92
+ }
93
+ }
94
+
95
+ override fun shouldDelayChildPressedState(): Boolean = false
96
+ }
97
+
98
+ /** Thematic break. */
99
+ class DividerView(context: Context) : View(context) {
100
+ var color: Int = 0
101
+ set(value) {
102
+ field = value
103
+ setBackgroundColor(value)
104
+ }
105
+ }
106
+
107
+ /** Block quote: paints its box style, hosts a nested stack inside padding. */
108
+ class QuoteView(context: Context) : ViewGroup(context) {
109
+ private var measured: MeasuredBlock? = null
110
+ private var block: Block.Quote? = null
111
+ private val stack = BlockStackView(context)
112
+
113
+ init {
114
+ setWillNotDraw(false)
115
+ addView(stack)
116
+ }
117
+
118
+ fun bind(
119
+ measuredBlock: MeasuredBlock,
120
+ quote: Block.Quote,
121
+ gap: Float,
122
+ host: MarkdownHost? = null,
123
+ ) {
124
+ measured = measuredBlock
125
+ block = quote
126
+ stack.host = host
127
+ stack.setBlocks(measuredBlock.children, gap)
128
+ invalidate()
129
+ }
130
+
131
+ override fun onDraw(canvas: Canvas) {
132
+ block?.let { BoxDrawing.draw(canvas, it.style, width.toFloat(), height.toFloat()) }
133
+ }
134
+
135
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
136
+ setMeasuredDimension(
137
+ MeasureSpec.getSize(widthMeasureSpec),
138
+ MeasureSpec.getSize(heightMeasureSpec),
139
+ )
140
+ }
141
+
142
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
143
+ val style = block?.style ?: return
144
+ val left = (style.borderLeftWidth + style.paddingLeft).toInt()
145
+ val top = (style.borderTopWidth + style.paddingTop).toInt()
146
+ val right = (r - l) - (style.borderRightWidth + style.paddingRight).toInt()
147
+ val bottom = (b - t) - (style.borderBottomWidth + style.paddingBottom).toInt()
148
+ stack.measure(
149
+ MeasureSpec.makeMeasureSpec(right - left, MeasureSpec.EXACTLY),
150
+ MeasureSpec.makeMeasureSpec(bottom - top, MeasureSpec.EXACTLY),
151
+ )
152
+ stack.layout(left, top, right, bottom)
153
+ }
154
+
155
+ override fun shouldDelayChildPressedState(): Boolean = false
156
+ }
157
+
158
+ /** Code block: paints its box, hosts unwrapped text in a horizontal scroller. */
159
+ class CodeBlockView(context: Context) : ViewGroup(context) {
160
+ private var measured: MeasuredBlock? = null
161
+ private var block: Block.Code? = null
162
+ private val scroller = NestedHorizontalScrollView(context).apply {
163
+ isHorizontalScrollBarEnabled = false
164
+ overScrollMode = OVER_SCROLL_NEVER
165
+ clipToPadding = false
166
+ }
167
+ private val textView = BlockTextView(context)
168
+
169
+ init {
170
+ setWillNotDraw(false)
171
+ scroller.addView(textView)
172
+ addView(scroller)
173
+ }
174
+
175
+ fun bind(measuredBlock: MeasuredBlock, code: Block.Code) {
176
+ measured = measuredBlock
177
+ block = code
178
+ measuredBlock.textLayout?.let(textView::setTextLayout)
179
+ invalidate()
180
+ requestLayout()
181
+ }
182
+
183
+ override fun onDraw(canvas: Canvas) {
184
+ block?.let { BoxDrawing.draw(canvas, it.style, width.toFloat(), height.toFloat()) }
185
+ }
186
+
187
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
188
+ setMeasuredDimension(
189
+ MeasureSpec.getSize(widthMeasureSpec),
190
+ MeasureSpec.getSize(heightMeasureSpec),
191
+ )
192
+ }
193
+
194
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
195
+ val style = block?.style ?: return
196
+ val contentWidth = measured?.contentWidthPx?.toInt() ?: 0
197
+ val textHeight = measured?.textLayout?.height ?: 0
198
+ val left = style.paddingLeft.toInt()
199
+ val top = style.paddingTop.toInt()
200
+ val right = (r - l) - style.paddingRight.toInt()
201
+
202
+ textView.measure(
203
+ MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY),
204
+ MeasureSpec.makeMeasureSpec(textHeight, MeasureSpec.EXACTLY),
205
+ )
206
+ scroller.measure(
207
+ MeasureSpec.makeMeasureSpec(right - left, MeasureSpec.EXACTLY),
208
+ MeasureSpec.makeMeasureSpec(textHeight, MeasureSpec.EXACTLY),
209
+ )
210
+ scroller.layout(left, top, right, top + textHeight)
211
+ }
212
+
213
+ override fun shouldDelayChildPressedState(): Boolean = false
214
+ }
215
+
216
+ /** List: rows of a fixed-width marker column and nested content stacks. */
217
+ class ListBlockView(context: Context) : ViewGroup(context) {
218
+ private var measured: MeasuredBlock? = null
219
+ private var block: Block.ListBlock? = null
220
+ private var gapPx = 0f
221
+
222
+ fun bind(
223
+ measuredBlock: MeasuredBlock,
224
+ list: Block.ListBlock,
225
+ gap: Float,
226
+ host: MarkdownHost? = null,
227
+ ) {
228
+ measured = measuredBlock
229
+ block = list
230
+ gapPx = gap
231
+ removeAllViews()
232
+ measuredBlock.markerLayouts.forEachIndexed { index, marker ->
233
+ addView(BlockTextView(context).apply { setTextLayout(marker) })
234
+ addView(BlockStackView(context).apply {
235
+ this.host = host
236
+ setBlocks(measuredBlock.rowContents[index], gap)
237
+ })
238
+ }
239
+ requestLayout()
240
+ }
241
+
242
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
243
+ setMeasuredDimension(
244
+ MeasureSpec.getSize(widthMeasureSpec),
245
+ MeasureSpec.getSize(heightMeasureSpec),
246
+ )
247
+ }
248
+
249
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
250
+ val list = block ?: return
251
+ val tree = measured ?: return
252
+ val markerX = (list.marginLeftPx + list.markerMarginLeftPx).toInt()
253
+ val contentX = (markerX + list.markerWidthPx).toInt()
254
+ val contentWidth = tree.contentWidthPx.toInt()
255
+
256
+ var y = 0
257
+ tree.markerLayouts.forEachIndexed { index, marker ->
258
+ val markerView = getChildAt(index * 2) ?: return@forEachIndexed
259
+ val contentView = getChildAt(index * 2 + 1) ?: return@forEachIndexed
260
+ val contentHeight = tree.rowContents[index].let { row ->
261
+ var height = 0f
262
+ row.forEachIndexed { i, child ->
263
+ height += child.heightPx
264
+ if (i < row.size - 1) {
265
+ height += gapPx
266
+ }
267
+ }
268
+ height
269
+ }
270
+ val rowHeight = maxOf(marker.height.toFloat(), contentHeight).toInt()
271
+
272
+ markerView.measure(
273
+ MeasureSpec.makeMeasureSpec(list.markerWidthPx.toInt(), MeasureSpec.EXACTLY),
274
+ MeasureSpec.makeMeasureSpec(marker.height, MeasureSpec.EXACTLY),
275
+ )
276
+ markerView.layout(markerX, y, contentX, y + marker.height)
277
+
278
+ contentView.measure(
279
+ MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY),
280
+ MeasureSpec.makeMeasureSpec(contentHeight.toInt(), MeasureSpec.EXACTLY),
281
+ )
282
+ contentView.layout(contentX, y, contentX + contentWidth, y + contentHeight.toInt())
283
+
284
+ y += rowHeight
285
+ if (index < tree.markerLayouts.size - 1) {
286
+ y += (gapPx / 2f).toInt()
287
+ }
288
+ }
289
+ }
290
+
291
+ override fun shouldDelayChildPressedState(): Boolean = false
292
+ }
@@ -0,0 +1,378 @@
1
+ package com.jetmarkdown.views
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Path
8
+ import android.graphics.RectF
9
+ import android.text.Spanned
10
+ import android.text.TextPaint
11
+ import android.text.StaticLayout
12
+ import android.view.MotionEvent
13
+ import android.view.View
14
+ import android.view.ViewConfiguration
15
+ import com.jetmarkdown.render.Block
16
+ import com.jetmarkdown.render.spans.ChipSpan
17
+ import com.jetmarkdown.render.spans.LinkSpan
18
+ import com.jetmarkdown.render.spans.SpoilerSpan
19
+ import kotlin.math.abs
20
+
21
+ /**
22
+ * Draws one block's StaticLayout plus spoiler covers, and hit-tests links,
23
+ * mentions, and spoilers by character range.
24
+ */
25
+ class BlockTextView(context: Context) : View(context) {
26
+ private var layout: StaticLayout? = null
27
+ private var textBlock: Block.Text? = null
28
+ var host: MarkdownHost? = null
29
+
30
+ private val coverPaint = Paint(Paint.ANTI_ALIAS_FLAG)
31
+ private val coverPath = Path()
32
+ private val chipPaint = Paint(Paint.ANTI_ALIAS_FLAG)
33
+ private val chipPath = Path()
34
+
35
+ private var downX = 0f
36
+ private var downY = 0f
37
+ private var longPressFired = false
38
+ private val longPressRunnable = Runnable {
39
+ longPressFired = true
40
+ pressedLink?.let { host?.onLinkLongPress(it.url) }
41
+ }
42
+ private var pressedLink: LinkSpan? = null
43
+ private var pressedSpoiler: SpoilerSpan? = null
44
+
45
+ fun setTextLayout(value: StaticLayout) {
46
+ if (layout !== value) {
47
+ layout = value
48
+ contentDescription = value.text
49
+ importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
50
+ requestLayout()
51
+ invalidate()
52
+ }
53
+ }
54
+
55
+ fun setBlock(block: Block.Text) {
56
+ textBlock = block
57
+ }
58
+
59
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
60
+ // HorizontalScrollView measures children UNSPECIFIED; report the text size.
61
+ val text = layout
62
+ if (text != null) {
63
+ setMeasuredDimension(
64
+ resolveSize(text.width, widthMeasureSpec),
65
+ resolveSize(text.height, heightMeasureSpec),
66
+ )
67
+ } else {
68
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
69
+ }
70
+ }
71
+
72
+ override fun onDraw(canvas: Canvas) {
73
+ val text = layout ?: return
74
+ drawChips(canvas, text)
75
+ drawTextHidingSpoilers(canvas, text)
76
+ drawSpoilerCovers(canvas, text)
77
+ }
78
+
79
+ // Unrevealed spoiler text is hidden by clipping its line-box slices out
80
+ // of the text draw. Purely draw-time, per-view state: the spannable is
81
+ // shared through the content cache and read by the Fabric layout thread,
82
+ // so it must never be mutated from the view.
83
+ private fun drawTextHidingSpoilers(canvas: Canvas, text: StaticLayout) {
84
+ val spanned = text.text as? Spanned
85
+ val hostRef = host
86
+ val spans = if (spanned != null && hostRef != null) {
87
+ spanned.getSpans(0, spanned.length, SpoilerSpan::class.java)
88
+ .filter { !hostRef.isSpoilerRevealed(it.id) }
89
+ } else {
90
+ emptyList()
91
+ }
92
+ if (spans.isEmpty() || spanned == null) {
93
+ text.draw(canvas)
94
+ return
95
+ }
96
+ canvas.save()
97
+ for (span in spans) {
98
+ val start = spanned.getSpanStart(span)
99
+ val end = spanned.getSpanEnd(span)
100
+ if (start >= end) {
101
+ continue
102
+ }
103
+ val firstLine = text.getLineForOffset(start)
104
+ val lastLine = text.getLineForOffset(end)
105
+ for (line in firstLine..lastLine) {
106
+ val segmentStart = maxOf(start, text.getLineStart(line))
107
+ val segmentEnd = minOf(end, text.getLineEnd(line))
108
+ if (segmentStart >= segmentEnd) {
109
+ continue
110
+ }
111
+ val left = text.getPrimaryHorizontal(segmentStart)
112
+ val right = if (segmentEnd < text.getLineEnd(line)) {
113
+ text.getPrimaryHorizontal(segmentEnd)
114
+ } else {
115
+ text.getLineRight(line)
116
+ }
117
+ // Full line box, padded past side bearings and italic overhang.
118
+ val rect = RectF(
119
+ minOf(left, right) - 2f,
120
+ text.getLineTop(line).toFloat(),
121
+ maxOf(left, right) + 2f,
122
+ text.getLineBottom(line).toFloat(),
123
+ )
124
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
125
+ canvas.clipOutRect(rect)
126
+ } else {
127
+ @Suppress("DEPRECATION")
128
+ canvas.clipRect(rect, android.graphics.Region.Op.DIFFERENCE)
129
+ }
130
+ }
131
+ }
132
+ text.draw(canvas)
133
+ canvas.restore()
134
+ }
135
+
136
+
137
+ // Overlays hug the text. lineHeight moves line boxes around the text, so
138
+ // any box derived from them inherits that skew; these rects anchor on the
139
+ // baseline instead. Horizontal comes from the segment's glyph ink;
140
+ // vertical is the FONT's ink envelope (cap height above the baseline,
141
+ // descender depth below) so every run of a font renders the same height
142
+ // whether or not its particular glyphs have capitals or descenders.
143
+ private val inkPadPx: Float
144
+ get() = 2f * resources.displayMetrics.density
145
+
146
+ private val inkPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
147
+ private val inkBounds = android.graphics.Rect()
148
+ private val capBounds = android.graphics.Rect()
149
+
150
+ /** Overlay rect for one line's segment of a run, padded. */
151
+ private fun inkRect(
152
+ text: StaticLayout,
153
+ line: Int,
154
+ segmentStart: Int,
155
+ segmentEnd: Int,
156
+ typeface: android.graphics.Typeface?,
157
+ textSizePx: Float,
158
+ padLeft: Float,
159
+ padRight: Float,
160
+ ): RectF? {
161
+ val chars = text.text.subSequence(segmentStart, segmentEnd).toString()
162
+ if (chars.isBlank()) {
163
+ return null
164
+ }
165
+ inkPaint.typeface = typeface
166
+ inkPaint.textSize = textSizePx
167
+ inkPaint.getTextBounds(chars, 0, chars.length, inkBounds)
168
+ if (inkBounds.isEmpty) {
169
+ return null
170
+ }
171
+ inkPaint.getTextBounds("H", 0, 1, capBounds)
172
+ val descent = inkPaint.fontMetrics.descent
173
+ val penX = text.getPrimaryHorizontal(segmentStart)
174
+ val right = if (segmentEnd < text.getLineEnd(line)) {
175
+ text.getPrimaryHorizontal(segmentEnd)
176
+ } else {
177
+ text.getLineRight(line)
178
+ }
179
+ val baseline = text.getLineBaseline(line).toFloat()
180
+ val left = minOf(penX, right)
181
+ return RectF(
182
+ (left + inkBounds.left - padLeft).coerceAtLeast(0f),
183
+ (baseline - capBounds.height() - inkPadPx).coerceAtLeast(0f),
184
+ (left + inkBounds.right + padRight).coerceAtMost(width.toFloat()),
185
+ (baseline + descent + inkPadPx).coerceAtMost(height.toFloat()),
186
+ )
187
+ }
188
+
189
+ // Run background chips (inlineCode/link/mention and plain highlights),
190
+ // drawn UNDER the text. Chips inside an unrevealed spoiler don't draw —
191
+ // they would peek around the cover.
192
+ private fun drawChips(canvas: Canvas, text: StaticLayout) {
193
+ val spanned = text.text as? Spanned ?: return
194
+ val spans = spanned.getSpans(0, spanned.length, ChipSpan::class.java)
195
+ if (spans.isEmpty()) {
196
+ return
197
+ }
198
+ chipPaint.style = Paint.Style.FILL
199
+ for (span in spans) {
200
+ val start = spanned.getSpanStart(span)
201
+ val end = spanned.getSpanEnd(span)
202
+ if (start >= end || isInsideHiddenSpoiler(spanned, start, end)) {
203
+ continue
204
+ }
205
+ chipPaint.color = span.color
206
+ val firstLine = text.getLineForOffset(start)
207
+ val lastLine = text.getLineForOffset(end)
208
+ for (line in firstLine..lastLine) {
209
+ val segmentStart = maxOf(start, text.getLineStart(line))
210
+ val segmentEnd = minOf(end, text.getLineEnd(line))
211
+ if (segmentStart >= segmentEnd) {
212
+ continue
213
+ }
214
+ val rect = inkRect(
215
+ text = text,
216
+ line = line,
217
+ segmentStart = segmentStart,
218
+ segmentEnd = segmentEnd,
219
+ typeface = span.typeface,
220
+ textSizePx = span.textSizePx,
221
+ padLeft = if (line == firstLine && span.padLeftPx > 0f) span.padLeftPx else inkPadPx,
222
+ padRight = if (line == lastLine && span.padRightPx > 0f) span.padRightPx else inkPadPx,
223
+ ) ?: continue
224
+ chipPath.reset()
225
+ chipPath.addRoundRect(rect, span.radiusPx, span.radiusPx, Path.Direction.CW)
226
+ canvas.drawPath(chipPath, chipPaint)
227
+ }
228
+ }
229
+ }
230
+
231
+ private fun isInsideHiddenSpoiler(spanned: Spanned, start: Int, end: Int): Boolean {
232
+ val hostRef = host ?: return false
233
+ return spanned.getSpans(start, end, SpoilerSpan::class.java)
234
+ .any { !hostRef.isSpoilerRevealed(it.id) }
235
+ }
236
+
237
+ // Spoiler cover chips, drawn OVER the (transparent) text until revealed.
238
+ // A spoiler with mixed inline styling carries one span per run (same id);
239
+ // per line the group's segment ink rects union into one cover.
240
+ private fun drawSpoilerCovers(canvas: Canvas, text: StaticLayout) {
241
+ val spanned = text.text as? Spanned ?: return
242
+ val hostRef = host ?: return
243
+ val spans = spanned.getSpans(0, spanned.length, SpoilerSpan::class.java)
244
+ if (spans.isEmpty()) {
245
+ return
246
+ }
247
+
248
+ coverPaint.style = Paint.Style.FILL
249
+ // Table cells carry no Block.Text; fall back to the functional floor
250
+ // (plain black, no radius) like iOS does.
251
+ coverPaint.color = textBlock?.spoilerColor ?: Color.BLACK
252
+ val coverRadiusPx = textBlock?.spoilerRadiusPx ?: 0f
253
+
254
+ for ((id, group) in spans.groupBy { it.id }) {
255
+ if (hostRef.isSpoilerRevealed(id)) {
256
+ continue
257
+ }
258
+ val start = group.minOf { spanned.getSpanStart(it) }
259
+ val end = group.maxOf { spanned.getSpanEnd(it) }
260
+ if (start >= end) {
261
+ continue
262
+ }
263
+ val firstLine = text.getLineForOffset(start)
264
+ val lastLine = text.getLineForOffset(end)
265
+ for (line in firstLine..lastLine) {
266
+ var union: RectF? = null
267
+ for (span in group) {
268
+ val spanStart = maxOf(spanned.getSpanStart(span), text.getLineStart(line))
269
+ val spanEnd = minOf(spanned.getSpanEnd(span), text.getLineEnd(line))
270
+ if (spanStart >= spanEnd) {
271
+ continue
272
+ }
273
+ val rect = inkRect(
274
+ text = text,
275
+ line = line,
276
+ segmentStart = spanStart,
277
+ segmentEnd = spanEnd,
278
+ typeface = span.typeface,
279
+ textSizePx = span.textSizePx,
280
+ padLeft = inkPadPx,
281
+ padRight = inkPadPx,
282
+ ) ?: continue
283
+ union = union?.apply { union(rect) } ?: rect
284
+ }
285
+ val rect = union ?: continue
286
+ coverPath.reset()
287
+ coverPath.addRoundRect(rect, coverRadiusPx, coverRadiusPx, Path.Direction.CW)
288
+ canvas.drawPath(coverPath, coverPaint)
289
+ }
290
+ }
291
+ }
292
+
293
+ override fun onTouchEvent(event: MotionEvent): Boolean {
294
+ val text = layout ?: return false
295
+ val spanned = text.text as? Spanned ?: return false
296
+
297
+ when (event.actionMasked) {
298
+ MotionEvent.ACTION_DOWN -> {
299
+ val offset = offsetAt(text, event.x, event.y) ?: return false
300
+ pressedLink = spanned.getSpans(offset, offset, LinkSpan::class.java).firstOrNull()
301
+ pressedSpoiler = spanned.getSpans(offset, offset, SpoilerSpan::class.java).firstOrNull()
302
+ if (pressedLink == null && pressedSpoiler == null) {
303
+ return false
304
+ }
305
+ downX = event.x
306
+ downY = event.y
307
+ longPressFired = false
308
+ if (pressedLink != null) {
309
+ postDelayed(longPressRunnable, ViewConfiguration.getLongPressTimeout().toLong())
310
+ }
311
+ // Tell ancestors (including gesture-handler roots, which honor this
312
+ // as "a native view took over") that this press is ours, so a
313
+ // wrapping Pressable does not also fire for link/spoiler taps.
314
+ parent?.requestDisallowInterceptTouchEvent(true)
315
+ return true
316
+ }
317
+ MotionEvent.ACTION_MOVE -> {
318
+ val slop = ViewConfiguration.get(context).scaledTouchSlop
319
+ if (abs(event.x - downX) > slop || abs(event.y - downY) > slop) {
320
+ cancelPress()
321
+ // The finger is dragging: hand the gesture back so scroll views
322
+ // can intercept and pan.
323
+ parent?.requestDisallowInterceptTouchEvent(false)
324
+ }
325
+ return pressedLink != null || pressedSpoiler != null
326
+ }
327
+ MotionEvent.ACTION_UP -> {
328
+ parent?.requestDisallowInterceptTouchEvent(false)
329
+ removeCallbacks(longPressRunnable)
330
+ if (!longPressFired) {
331
+ val spoiler = pressedSpoiler
332
+ val link = pressedLink
333
+ val hostRef = host
334
+ if (spoiler != null && hostRef?.isSpoilerRevealed(spoiler.id) == false) {
335
+ // First tap reveals; links inside come alive afterwards.
336
+ hostRef.toggleSpoiler(spoiler.id)
337
+ } else if (link != null) {
338
+ hostRef?.onLinkPress(link.url)
339
+ } else if (spoiler != null) {
340
+ hostRef?.toggleSpoiler(spoiler.id)
341
+ }
342
+ }
343
+ cancelPress()
344
+ return true
345
+ }
346
+ MotionEvent.ACTION_CANCEL -> {
347
+ parent?.requestDisallowInterceptTouchEvent(false)
348
+ cancelPress()
349
+ return false
350
+ }
351
+ }
352
+ return false
353
+ }
354
+
355
+ override fun onDetachedFromWindow() {
356
+ super.onDetachedFromWindow()
357
+ // A teardown mid-press would otherwise fire the runnable ~500ms later
358
+ // against a recycled host.
359
+ cancelPress()
360
+ }
361
+
362
+ private fun cancelPress() {
363
+ removeCallbacks(longPressRunnable)
364
+ pressedLink = null
365
+ pressedSpoiler = null
366
+ }
367
+
368
+ private fun offsetAt(text: StaticLayout, x: Float, y: Float): Int? {
369
+ if (y < 0 || y > text.height) {
370
+ return null
371
+ }
372
+ val line = text.getLineForVertical(y.toInt())
373
+ if (x < text.getLineLeft(line) - 8 || x > text.getLineRight(line) + 8) {
374
+ return null
375
+ }
376
+ return text.getOffsetForHorizontal(line, x)
377
+ }
378
+ }