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.
- package/JetMarkdown.podspec +42 -0
- package/LICENSE +20 -0
- package/README.md +247 -0
- package/android/CMakeLists.txt +52 -0
- package/android/build.gradle +55 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/JniBindings.cpp +243 -0
- package/android/src/main/java/com/jetmarkdown/JetMarkdownNative.kt +114 -0
- package/android/src/main/java/com/jetmarkdown/JetMarkdownPackage.kt +22 -0
- package/android/src/main/java/com/jetmarkdown/JetMarkdownView.kt +235 -0
- package/android/src/main/java/com/jetmarkdown/JetMarkdownViewManager.kt +75 -0
- package/android/src/main/java/com/jetmarkdown/editor/EditorSpans.kt +217 -0
- package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorManager.kt +224 -0
- package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorView.kt +1736 -0
- package/android/src/main/java/com/jetmarkdown/measure/MarkdownMeasurer.kt +54 -0
- package/android/src/main/java/com/jetmarkdown/parser/AstDecoder.kt +59 -0
- package/android/src/main/java/com/jetmarkdown/parser/MdNode.kt +43 -0
- package/android/src/main/java/com/jetmarkdown/render/ContentCache.kt +53 -0
- package/android/src/main/java/com/jetmarkdown/render/RenderedContent.kt +318 -0
- package/android/src/main/java/com/jetmarkdown/render/SpannableRenderer.kt +658 -0
- package/android/src/main/java/com/jetmarkdown/render/spans/ChipSpan.kt +17 -0
- package/android/src/main/java/com/jetmarkdown/render/spans/InteractiveSpans.kt +11 -0
- package/android/src/main/java/com/jetmarkdown/render/spans/MarkdownLineHeightSpan.kt +59 -0
- package/android/src/main/java/com/jetmarkdown/render/spans/RunSpan.kt +40 -0
- package/android/src/main/java/com/jetmarkdown/style/Fonts.kt +34 -0
- package/android/src/main/java/com/jetmarkdown/style/LayoutStyleSpec.kt +71 -0
- package/android/src/main/java/com/jetmarkdown/style/PlatformColorResolver.kt +77 -0
- package/android/src/main/java/com/jetmarkdown/style/StyleConfig.kt +80 -0
- package/android/src/main/java/com/jetmarkdown/style/TextStyleSpec.kt +61 -0
- package/android/src/main/java/com/jetmarkdown/views/BlockStackView.kt +292 -0
- package/android/src/main/java/com/jetmarkdown/views/BlockTextView.kt +378 -0
- package/android/src/main/java/com/jetmarkdown/views/BoxDrawing.kt +50 -0
- package/android/src/main/java/com/jetmarkdown/views/MarkdownHost.kt +12 -0
- package/android/src/main/java/com/jetmarkdown/views/MarkdownImageView.kt +113 -0
- package/android/src/main/java/com/jetmarkdown/views/NestedHorizontalScrollView.kt +87 -0
- package/android/src/main/java/com/jetmarkdown/views/TableBlockView.kt +139 -0
- package/cpp/core/Ast.h +85 -0
- package/cpp/core/AstJson.cpp +129 -0
- package/cpp/core/AstJson.h +12 -0
- package/cpp/core/AstSerializer.cpp +40 -0
- package/cpp/core/AstSerializer.h +21 -0
- package/cpp/core/AstToMarkdown.cpp +549 -0
- package/cpp/core/AstToMarkdown.h +16 -0
- package/cpp/core/EditorRuns.cpp +640 -0
- package/cpp/core/EditorRuns.h +94 -0
- package/cpp/core/EditorText.cpp +15 -0
- package/cpp/core/EditorText.h +18 -0
- package/cpp/core/InlineExtensions.cpp +305 -0
- package/cpp/core/InlineExtensions.h +17 -0
- package/cpp/core/Parser.cpp +470 -0
- package/cpp/core/Parser.h +16 -0
- package/cpp/core/Preprocess.cpp +181 -0
- package/cpp/core/Preprocess.h +14 -0
- package/cpp/md4c/LICENSE +22 -0
- package/cpp/md4c/VERSION +1 -0
- package/cpp/md4c/md4c.c +6462 -0
- package/cpp/md4c/md4c.h +407 -0
- package/cpp/react/JetMarkdownEditorShadowNode.cpp +54 -0
- package/cpp/react/JetMarkdownEditorShadowNode.h +55 -0
- package/cpp/react/JetMarkdownEditorState.h +35 -0
- package/cpp/react/JetMarkdownMeasurer.cpp +32 -0
- package/cpp/react/JetMarkdownMeasurer.h +41 -0
- package/cpp/react/JetMarkdownShadowNode.cpp +100 -0
- package/cpp/react/JetMarkdownShadowNode.h +53 -0
- package/cpp/react/JetMarkdownState.h +52 -0
- package/cpp/react/override/react/renderer/components/JetMarkdownViewSpec/ComponentDescriptors.h +21 -0
- package/cpp/tests/parser_tests.cpp +662 -0
- package/cpp/tests/run_tests.sh +25 -0
- package/ios/JetMarkdownView.h +14 -0
- package/ios/JetMarkdownView.mm +468 -0
- package/ios/editor/JetMarkdownEditor.h +12 -0
- package/ios/editor/JetMarkdownEditor.mm +2002 -0
- package/ios/measure/JMDMarkdownMeasurer.h +22 -0
- package/ios/measure/JMDMarkdownMeasurer.mm +38 -0
- package/ios/render/JMDBlock.h +97 -0
- package/ios/render/JMDBlock.m +28 -0
- package/ios/render/JMDBlockRenderer.h +17 -0
- package/ios/render/JMDBlockRenderer.mm +852 -0
- package/ios/render/JMDContentCache.h +17 -0
- package/ios/render/JMDContentCache.mm +42 -0
- package/ios/render/JMDRenderedContent.h +35 -0
- package/ios/render/JMDRenderedContent.mm +274 -0
- package/ios/style/JMDFontScale.h +33 -0
- package/ios/style/JMDLayoutStyle.h +37 -0
- package/ios/style/JMDLayoutStyle.mm +68 -0
- package/ios/style/JMDStyleConfig.h +39 -0
- package/ios/style/JMDStyleConfig.mm +111 -0
- package/ios/style/JMDTextStyle.h +25 -0
- package/ios/style/JMDTextStyle.mm +68 -0
- package/ios/views/JMDBlockStackView.h +18 -0
- package/ios/views/JMDBlockStackView.m +97 -0
- package/ios/views/JMDBlockTextView.h +24 -0
- package/ios/views/JMDBlockTextView.m +340 -0
- package/ios/views/JMDBoxViews.h +33 -0
- package/ios/views/JMDBoxViews.m +292 -0
- package/ios/views/JMDImageView.h +19 -0
- package/ios/views/JMDImageView.m +68 -0
- package/ios/views/JMDMarkdownHost.h +16 -0
- package/ios/views/JMDTableView.h +16 -0
- package/ios/views/JMDTableView.m +172 -0
- package/lib/module/JetMarkdownEditor.js +85 -0
- package/lib/module/JetMarkdownEditor.js.map +1 -0
- package/lib/module/JetMarkdownEditor.native.js +244 -0
- package/lib/module/JetMarkdownEditor.native.js.map +1 -0
- package/lib/module/JetMarkdownEditorNativeComponent.ts +173 -0
- package/lib/module/JetMarkdownView.js +19 -0
- package/lib/module/JetMarkdownView.js.map +1 -0
- package/lib/module/JetMarkdownView.native.js +44 -0
- package/lib/module/JetMarkdownView.native.js.map +1 -0
- package/lib/module/JetMarkdownViewNativeComponent.ts +35 -0
- package/lib/module/defaultStyles.js +122 -0
- package/lib/module/defaultStyles.js.map +1 -0
- package/lib/module/index.js +9 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/serializeStyles.js +259 -0
- package/lib/module/serializeStyles.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/useJetMarkdownEditor.js +41 -0
- package/lib/module/useJetMarkdownEditor.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/JetMarkdownEditor.d.ts +6 -0
- package/lib/typescript/src/JetMarkdownEditor.d.ts.map +1 -0
- package/lib/typescript/src/JetMarkdownEditor.native.d.ts +6 -0
- package/lib/typescript/src/JetMarkdownEditor.native.d.ts.map +1 -0
- package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts +105 -0
- package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/JetMarkdownView.d.ts +3 -0
- package/lib/typescript/src/JetMarkdownView.d.ts.map +1 -0
- package/lib/typescript/src/JetMarkdownView.native.d.ts +3 -0
- package/lib/typescript/src/JetMarkdownView.native.d.ts.map +1 -0
- package/lib/typescript/src/JetMarkdownViewNativeComponent.d.ts +28 -0
- package/lib/typescript/src/JetMarkdownViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/defaultStyles.d.ts +20 -0
- package/lib/typescript/src/defaultStyles.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +7 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/serializeStyles.d.ts +34 -0
- package/lib/typescript/src/serializeStyles.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +352 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/useJetMarkdownEditor.d.ts +36 -0
- package/lib/typescript/src/useJetMarkdownEditor.d.ts.map +1 -0
- package/package.json +150 -0
- package/react-native.config.js +11 -0
- package/src/JetMarkdownEditor.native.tsx +301 -0
- package/src/JetMarkdownEditor.tsx +90 -0
- package/src/JetMarkdownEditorNativeComponent.ts +173 -0
- package/src/JetMarkdownView.native.tsx +67 -0
- package/src/JetMarkdownView.tsx +16 -0
- package/src/JetMarkdownViewNativeComponent.ts +35 -0
- package/src/defaultStyles.ts +102 -0
- package/src/index.tsx +34 -0
- package/src/serializeStyles.ts +369 -0
- package/src/types.ts +438 -0
- 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
|
+
}
|