react-native-enriched-markdown 0.1.0 → 0.1.1
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/LICENSE +20 -0
- package/README.md +479 -0
- package/ReactNativeEnrichedMarkdown.podspec +27 -0
- package/android/build.gradle +101 -0
- package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +39 -0
- package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +21 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ComponentDescriptors.cpp +22 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ComponentDescriptors.h +24 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +24 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +25 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +57 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +1164 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ShadowNodes.cpp +17 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ShadowNodes.h +32 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/States.cpp +16 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/States.h +20 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/baseline-prof.txt +65 -0
- package/android/src/main/cpp/jni-adapter.cpp +203 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +153 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +30 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +119 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextPackage.kt +17 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +165 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkPressEvent.kt +23 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +29 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +48 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +166 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +89 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +105 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeRenderer.kt +35 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt +15 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/EmphasisRenderer.kt +26 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +54 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ImageRenderer.kt +52 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LineBreakRenderer.kt +15 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +28 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt +105 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +58 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +69 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +99 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt +66 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt +95 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +85 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrongRenderer.kt +26 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/TextRenderer.kt +29 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ThematicBreakRenderer.kt +44 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +136 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt +135 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +180 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +196 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +27 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/EmphasisSpan.kt +34 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/HeadingSpan.kt +38 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +320 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +36 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +37 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/MarginBottomSpan.kt +76 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +87 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +37 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/TextSpan.kt +26 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/ThematicBreakSpan.kt +69 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +69 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/BaseBlockStyle.kt +10 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +48 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +51 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +21 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/EmphasisStyle.kt +17 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/HeadingStyle.kt +29 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/ImageStyle.kt +21 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineImageStyle.kt +17 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +19 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/ListStyle.kt +54 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/ParagraphStyle.kt +29 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrongStyle.kt +17 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +180 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleParser.kt +75 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/ThematicBreakStyle.kt +23 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/AsyncDrawable.kt +91 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/HTMLGenerator.kt +809 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/MarkdownExtractor.kt +365 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/SelectionActionMode.kt +139 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/Utils.kt +181 -0
- package/android/src/main/jni/CMakeLists.txt +82 -0
- package/android/src/main/jni/EnrichedMarkdownTextSpec.cpp +21 -0
- package/android/src/main/jni/EnrichedMarkdownTextSpec.h +25 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextComponentDescriptor.h +29 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.cpp +45 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.h +21 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +33 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +49 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.cpp +9 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.h +25 -0
- package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +19 -0
- package/cpp/md4c/md4c.c +6492 -0
- package/cpp/md4c/md4c.h +402 -0
- package/cpp/parser/MD4CParser.cpp +314 -0
- package/cpp/parser/MD4CParser.hpp +23 -0
- package/cpp/parser/MarkdownASTNode.hpp +49 -0
- package/ios/EnrichedMarkdownText.h +18 -0
- package/ios/EnrichedMarkdownText.mm +1074 -0
- package/ios/attachments/ImageAttachment.h +23 -0
- package/ios/attachments/ImageAttachment.m +185 -0
- package/ios/attachments/ThematicBreakAttachment.h +15 -0
- package/ios/attachments/ThematicBreakAttachment.m +33 -0
- package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.cpp +22 -0
- package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.h +24 -0
- package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.cpp +24 -0
- package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.h +25 -0
- package/ios/generated/EnrichedMarkdownTextSpec/Props.cpp +57 -0
- package/ios/generated/EnrichedMarkdownTextSpec/Props.h +1164 -0
- package/ios/generated/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +20 -0
- package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.cpp +17 -0
- package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.h +32 -0
- package/ios/generated/EnrichedMarkdownTextSpec/States.cpp +16 -0
- package/ios/generated/EnrichedMarkdownTextSpec/States.h +20 -0
- package/ios/internals/EnrichedMarkdownTextComponentDescriptor.h +19 -0
- package/ios/internals/EnrichedMarkdownTextShadowNode.h +43 -0
- package/ios/internals/EnrichedMarkdownTextShadowNode.mm +85 -0
- package/ios/internals/EnrichedMarkdownTextState.h +24 -0
- package/ios/parser/MarkdownASTNode.h +33 -0
- package/ios/parser/MarkdownASTNode.m +32 -0
- package/ios/parser/MarkdownParser.h +8 -0
- package/ios/parser/MarkdownParser.mm +13 -0
- package/ios/parser/MarkdownParserBridge.mm +110 -0
- package/ios/renderer/AttributedRenderer.h +9 -0
- package/ios/renderer/AttributedRenderer.m +119 -0
- package/ios/renderer/BlockquoteRenderer.h +7 -0
- package/ios/renderer/BlockquoteRenderer.m +159 -0
- package/ios/renderer/CodeBlockRenderer.h +10 -0
- package/ios/renderer/CodeBlockRenderer.m +89 -0
- package/ios/renderer/CodeRenderer.h +10 -0
- package/ios/renderer/CodeRenderer.m +60 -0
- package/ios/renderer/EmphasisRenderer.h +6 -0
- package/ios/renderer/EmphasisRenderer.m +96 -0
- package/ios/renderer/HeadingRenderer.h +7 -0
- package/ios/renderer/HeadingRenderer.m +98 -0
- package/ios/renderer/ImageRenderer.h +12 -0
- package/ios/renderer/ImageRenderer.m +62 -0
- package/ios/renderer/LinkRenderer.h +7 -0
- package/ios/renderer/LinkRenderer.m +69 -0
- package/ios/renderer/ListItemRenderer.h +16 -0
- package/ios/renderer/ListItemRenderer.m +91 -0
- package/ios/renderer/ListRenderer.h +13 -0
- package/ios/renderer/ListRenderer.m +67 -0
- package/ios/renderer/NodeRenderer.h +8 -0
- package/ios/renderer/ParagraphRenderer.h +7 -0
- package/ios/renderer/ParagraphRenderer.m +69 -0
- package/ios/renderer/RenderContext.h +88 -0
- package/ios/renderer/RenderContext.m +248 -0
- package/ios/renderer/RendererFactory.h +12 -0
- package/ios/renderer/RendererFactory.m +110 -0
- package/ios/renderer/StrongRenderer.h +6 -0
- package/ios/renderer/StrongRenderer.m +83 -0
- package/ios/renderer/TextRenderer.h +6 -0
- package/ios/renderer/TextRenderer.m +16 -0
- package/ios/renderer/ThematicBreakRenderer.h +5 -0
- package/ios/renderer/ThematicBreakRenderer.m +53 -0
- package/ios/styles/StyleConfig.h +228 -0
- package/ios/styles/StyleConfig.mm +1467 -0
- package/ios/utils/BlockquoteBorder.h +20 -0
- package/ios/utils/BlockquoteBorder.m +92 -0
- package/ios/utils/CodeBackground.h +19 -0
- package/ios/utils/CodeBackground.m +191 -0
- package/ios/utils/CodeBlockBackground.h +17 -0
- package/ios/utils/CodeBlockBackground.m +87 -0
- package/ios/utils/EditMenuUtils.h +22 -0
- package/ios/utils/EditMenuUtils.m +118 -0
- package/ios/utils/FontUtils.h +20 -0
- package/ios/utils/FontUtils.m +13 -0
- package/ios/utils/HTMLGenerator.h +20 -0
- package/ios/utils/HTMLGenerator.m +779 -0
- package/ios/utils/LastElementUtils.h +53 -0
- package/ios/utils/ListMarkerDrawer.h +15 -0
- package/ios/utils/ListMarkerDrawer.m +127 -0
- package/ios/utils/MarkdownExtractor.h +17 -0
- package/ios/utils/MarkdownExtractor.m +295 -0
- package/ios/utils/ParagraphStyleUtils.h +13 -0
- package/ios/utils/ParagraphStyleUtils.m +56 -0
- package/ios/utils/PasteboardUtils.h +36 -0
- package/ios/utils/PasteboardUtils.m +134 -0
- package/ios/utils/RTFExportUtils.h +24 -0
- package/ios/utils/RTFExportUtils.m +297 -0
- package/ios/utils/RuntimeKeys.h +38 -0
- package/ios/utils/RuntimeKeys.m +11 -0
- package/ios/utils/TextViewLayoutManager.h +14 -0
- package/ios/utils/TextViewLayoutManager.mm +113 -0
- package/lib/module/EnrichedMarkdownText.js +34 -0
- package/lib/module/EnrichedMarkdownText.js.map +1 -0
- package/lib/module/EnrichedMarkdownTextNativeComponent.ts +130 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/normalizeMarkdownStyle.js +340 -0
- package/lib/module/normalizeMarkdownStyle.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/EnrichedMarkdownText.d.ts +101 -0
- package/lib/typescript/src/EnrichedMarkdownText.d.ts.map +1 -0
- package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +111 -0
- package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +5 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/normalizeMarkdownStyle.d.ts +6 -0
- package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -0
- package/package.json +186 -1
- package/react-native.config.js +13 -0
- package/src/EnrichedMarkdownText.tsx +152 -0
- package/src/EnrichedMarkdownTextNativeComponent.ts +130 -0
- package/src/index.tsx +7 -0
- package/src/normalizeMarkdownStyle.ts +377 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
package com.swmansion.enriched.markdown.utils
|
|
2
|
+
|
|
3
|
+
import android.text.Spannable
|
|
4
|
+
import android.widget.TextView
|
|
5
|
+
import com.swmansion.enriched.markdown.EnrichedMarkdownText
|
|
6
|
+
import com.swmansion.enriched.markdown.spans.BlockquoteSpan
|
|
7
|
+
import com.swmansion.enriched.markdown.spans.CodeBlockSpan
|
|
8
|
+
import com.swmansion.enriched.markdown.spans.CodeSpan
|
|
9
|
+
import com.swmansion.enriched.markdown.spans.EmphasisSpan
|
|
10
|
+
import com.swmansion.enriched.markdown.spans.HeadingSpan
|
|
11
|
+
import com.swmansion.enriched.markdown.spans.ImageSpan
|
|
12
|
+
import com.swmansion.enriched.markdown.spans.LinkSpan
|
|
13
|
+
import com.swmansion.enriched.markdown.spans.OrderedListSpan
|
|
14
|
+
import com.swmansion.enriched.markdown.spans.StrongSpan
|
|
15
|
+
import com.swmansion.enriched.markdown.spans.ThematicBreakSpan
|
|
16
|
+
import com.swmansion.enriched.markdown.spans.UnorderedListSpan
|
|
17
|
+
|
|
18
|
+
/** Extracts markdown from styled text (Spannable). */
|
|
19
|
+
object MarkdownExtractor {
|
|
20
|
+
/**
|
|
21
|
+
* Gets markdown for the current text selection.
|
|
22
|
+
* Full selection returns original markdown, partial reconstructs from spans.
|
|
23
|
+
*/
|
|
24
|
+
fun getMarkdownForSelection(textView: TextView): String? {
|
|
25
|
+
val start = textView.selectionStart
|
|
26
|
+
val end = textView.selectionEnd
|
|
27
|
+
if (start < 0 || end < 0 || start >= end) return null
|
|
28
|
+
|
|
29
|
+
val spannable = textView.text as? Spannable ?: return null
|
|
30
|
+
|
|
31
|
+
val isFullSelection = start == 0 && end >= textView.text.length - 1
|
|
32
|
+
if (isFullSelection && textView is EnrichedMarkdownText) {
|
|
33
|
+
val original = textView.currentMarkdown
|
|
34
|
+
if (original.isNotEmpty()) return original
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return extractFromSpannable(spannable, start, end)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extracts markdown from a Spannable within a given range. */
|
|
41
|
+
fun extractFromSpannable(
|
|
42
|
+
spannable: Spannable,
|
|
43
|
+
start: Int,
|
|
44
|
+
end: Int,
|
|
45
|
+
): String {
|
|
46
|
+
val result = StringBuilder()
|
|
47
|
+
val state = ExtractionState()
|
|
48
|
+
val headingAccumulator = HeadingAccumulator()
|
|
49
|
+
|
|
50
|
+
var i = start
|
|
51
|
+
while (i < end) {
|
|
52
|
+
val nextTransition = spannable.nextSpanTransition(i, end, Any::class.java)
|
|
53
|
+
val segmentText = spannable.subSequence(i, nextTransition).toString()
|
|
54
|
+
|
|
55
|
+
val handled =
|
|
56
|
+
processSegment(
|
|
57
|
+
spannable = spannable,
|
|
58
|
+
segmentText = segmentText,
|
|
59
|
+
segmentStart = i,
|
|
60
|
+
segmentEnd = nextTransition,
|
|
61
|
+
result = result,
|
|
62
|
+
state = state,
|
|
63
|
+
headingAccumulator = headingAccumulator,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (!handled) {
|
|
67
|
+
// Regular text segment - apply inline formatting and block prefixes
|
|
68
|
+
appendFormattedSegment(spannable, segmentText, i, nextTransition, result, state)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
i = nextTransition
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
headingAccumulator.flush(result, state)
|
|
75
|
+
return result.toString()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Returns true if segment was handled specially. */
|
|
79
|
+
private fun processSegment(
|
|
80
|
+
spannable: Spannable,
|
|
81
|
+
segmentText: String,
|
|
82
|
+
segmentStart: Int,
|
|
83
|
+
segmentEnd: Int,
|
|
84
|
+
result: StringBuilder,
|
|
85
|
+
state: ExtractionState,
|
|
86
|
+
headingAccumulator: HeadingAccumulator,
|
|
87
|
+
): Boolean {
|
|
88
|
+
// Check for thematic breaks first (uses " \n" as placeholder)
|
|
89
|
+
val thematicBreakSpans = spannable.getSpans(segmentStart, segmentEnd, ThematicBreakSpan::class.java)
|
|
90
|
+
if (thematicBreakSpans.isNotEmpty()) {
|
|
91
|
+
appendThematicBreak(result, state)
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (segmentText == "\uFFFC" || segmentText == "\u200B") {
|
|
96
|
+
val imageSpans = spannable.getSpans(segmentStart, segmentEnd, ImageSpan::class.java)
|
|
97
|
+
if (imageSpans.isNotEmpty()) {
|
|
98
|
+
appendImage(imageSpans[0], result, state)
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (segmentText.isEmpty()) return true
|
|
104
|
+
|
|
105
|
+
if (segmentText == "\n" || segmentText == "\n\n") {
|
|
106
|
+
handleNewline(spannable, segmentStart, segmentEnd, result, state)
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
val headingSpans = spannable.getSpans(segmentStart, segmentEnd, HeadingSpan::class.java)
|
|
111
|
+
if (headingSpans.isNotEmpty()) {
|
|
112
|
+
headingAccumulator.accumulate(headingSpans[0].level, segmentText, result, state)
|
|
113
|
+
return true
|
|
114
|
+
} else {
|
|
115
|
+
headingAccumulator.flush(result, state)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
val codeBlockSpans = spannable.getSpans(segmentStart, segmentEnd, CodeBlockSpan::class.java)
|
|
119
|
+
if (codeBlockSpans.isNotEmpty()) {
|
|
120
|
+
appendCodeBlock(segmentText, result, state)
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun appendFormattedSegment(
|
|
128
|
+
spannable: Spannable,
|
|
129
|
+
segmentText: String,
|
|
130
|
+
segmentStart: Int,
|
|
131
|
+
segmentEnd: Int,
|
|
132
|
+
result: StringBuilder,
|
|
133
|
+
state: ExtractionState,
|
|
134
|
+
) {
|
|
135
|
+
val blockquotePrefix = detectBlockquote(spannable, segmentStart, segmentEnd, state)
|
|
136
|
+
val listPrefix = detectList(spannable, segmentStart, segmentEnd, state)
|
|
137
|
+
var segment = applyInlineFormatting(spannable, segmentText, segmentStart, segmentEnd)
|
|
138
|
+
|
|
139
|
+
if (result.isAtLineStart() && !segmentText.startsWith("\n")) {
|
|
140
|
+
segment = buildBlockPrefix(blockquotePrefix, listPrefix) + segment
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (state.needsBlankLine && result.isNotEmpty()) {
|
|
144
|
+
result.ensureBlankLine()
|
|
145
|
+
state.needsBlankLine = false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
result.append(segment)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private fun appendImage(
|
|
152
|
+
img: ImageSpan,
|
|
153
|
+
result: StringBuilder,
|
|
154
|
+
state: ExtractionState,
|
|
155
|
+
) {
|
|
156
|
+
if (img.isInline) {
|
|
157
|
+
result.append("")
|
|
158
|
+
} else {
|
|
159
|
+
result.ensureBlankLine()
|
|
160
|
+
result.append("\n")
|
|
161
|
+
state.needsBlankLine = true
|
|
162
|
+
state.blockquoteDepth = -1
|
|
163
|
+
state.listDepth = -1
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fun appendThematicBreak(
|
|
168
|
+
result: StringBuilder,
|
|
169
|
+
state: ExtractionState,
|
|
170
|
+
) {
|
|
171
|
+
result.ensureBlankLine()
|
|
172
|
+
result.append("---\n")
|
|
173
|
+
state.needsBlankLine = true
|
|
174
|
+
state.blockquoteDepth = -1
|
|
175
|
+
state.listDepth = -1
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private fun handleNewline(
|
|
179
|
+
spannable: Spannable,
|
|
180
|
+
start: Int,
|
|
181
|
+
end: Int,
|
|
182
|
+
result: StringBuilder,
|
|
183
|
+
state: ExtractionState,
|
|
184
|
+
) {
|
|
185
|
+
val inBlockquote = spannable.getSpans(start, end, BlockquoteSpan::class.java).isNotEmpty()
|
|
186
|
+
val inList =
|
|
187
|
+
spannable.getSpans(start, end, OrderedListSpan::class.java).isNotEmpty() ||
|
|
188
|
+
spannable.getSpans(start, end, UnorderedListSpan::class.java).isNotEmpty()
|
|
189
|
+
|
|
190
|
+
when {
|
|
191
|
+
!inBlockquote && state.blockquoteDepth >= 0 -> {
|
|
192
|
+
result.ensureBlankLine()
|
|
193
|
+
state.blockquoteDepth = -1
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
!inList && state.listDepth >= 0 -> {
|
|
197
|
+
result.ensureBlankLine()
|
|
198
|
+
state.listDepth = -1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
inBlockquote || inList -> {
|
|
202
|
+
if (!result.endsWith("\n")) result.append("\n")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
else -> {
|
|
206
|
+
result.ensureBlankLine()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun appendCodeBlock(
|
|
212
|
+
text: String,
|
|
213
|
+
result: StringBuilder,
|
|
214
|
+
state: ExtractionState,
|
|
215
|
+
) {
|
|
216
|
+
if (state.needsBlankLine) {
|
|
217
|
+
result.ensureBlankLine()
|
|
218
|
+
state.needsBlankLine = false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
val needsFence = result.isEmpty() || result.endsWith("\n\n")
|
|
222
|
+
if (needsFence) result.append("```\n")
|
|
223
|
+
|
|
224
|
+
result.append(text)
|
|
225
|
+
|
|
226
|
+
if (text.endsWith("\n")) {
|
|
227
|
+
result.append("```\n")
|
|
228
|
+
state.needsBlankLine = true
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private fun detectBlockquote(
|
|
233
|
+
spannable: Spannable,
|
|
234
|
+
start: Int,
|
|
235
|
+
end: Int,
|
|
236
|
+
state: ExtractionState,
|
|
237
|
+
): String? {
|
|
238
|
+
val spans = spannable.getSpans(start, end, BlockquoteSpan::class.java)
|
|
239
|
+
val depth = spans.maxOfOrNull { it.depth } ?: -1
|
|
240
|
+
|
|
241
|
+
return if (depth >= 0) {
|
|
242
|
+
state.blockquoteDepth = depth
|
|
243
|
+
"> ".repeat(depth + 1)
|
|
244
|
+
} else {
|
|
245
|
+
if (state.blockquoteDepth >= 0) state.blockquoteDepth = -1
|
|
246
|
+
null
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private fun detectList(
|
|
251
|
+
spannable: Spannable,
|
|
252
|
+
start: Int,
|
|
253
|
+
end: Int,
|
|
254
|
+
state: ExtractionState,
|
|
255
|
+
): String? {
|
|
256
|
+
val orderedSpans = spannable.getSpans(start, end, OrderedListSpan::class.java)
|
|
257
|
+
val unorderedSpans = spannable.getSpans(start, end, UnorderedListSpan::class.java)
|
|
258
|
+
|
|
259
|
+
val orderedDepth = orderedSpans.maxOfOrNull { it.depth } ?: -1
|
|
260
|
+
val unorderedDepth = unorderedSpans.maxOfOrNull { it.depth } ?: -1
|
|
261
|
+
val depth = maxOf(orderedDepth, unorderedDepth)
|
|
262
|
+
|
|
263
|
+
return if (depth >= 0) {
|
|
264
|
+
state.listDepth = depth
|
|
265
|
+
val indent = " ".repeat(depth)
|
|
266
|
+
if (orderedSpans.isNotEmpty()) {
|
|
267
|
+
"$indent${orderedSpans[0].itemNumber}. "
|
|
268
|
+
} else {
|
|
269
|
+
"$indent- "
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
if (state.listDepth >= 0) state.listDepth = -1
|
|
273
|
+
null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private fun applyInlineFormatting(
|
|
278
|
+
spannable: Spannable,
|
|
279
|
+
text: String,
|
|
280
|
+
start: Int,
|
|
281
|
+
end: Int,
|
|
282
|
+
): String {
|
|
283
|
+
val hasStrong = spannable.getSpans(start, end, StrongSpan::class.java).isNotEmpty()
|
|
284
|
+
val hasEmphasis = spannable.getSpans(start, end, EmphasisSpan::class.java).isNotEmpty()
|
|
285
|
+
val hasCode = spannable.getSpans(start, end, CodeSpan::class.java).isNotEmpty()
|
|
286
|
+
val linkSpans = spannable.getSpans(start, end, LinkSpan::class.java)
|
|
287
|
+
|
|
288
|
+
var result = text
|
|
289
|
+
|
|
290
|
+
// Innermost first
|
|
291
|
+
if (hasCode && linkSpans.isEmpty()) {
|
|
292
|
+
result = "`$result`"
|
|
293
|
+
}
|
|
294
|
+
if (hasEmphasis) {
|
|
295
|
+
result = "*$result*"
|
|
296
|
+
}
|
|
297
|
+
if (hasStrong) {
|
|
298
|
+
result = "**$result**"
|
|
299
|
+
}
|
|
300
|
+
if (linkSpans.isNotEmpty()) {
|
|
301
|
+
result = "[$text](${linkSpans[0].url})"
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return result
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private fun buildBlockPrefix(
|
|
308
|
+
blockquotePrefix: String?,
|
|
309
|
+
listPrefix: String?,
|
|
310
|
+
): String =
|
|
311
|
+
buildString {
|
|
312
|
+
blockquotePrefix?.let { append(it) }
|
|
313
|
+
listPrefix?.let { append(it) }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private data class ExtractionState(
|
|
317
|
+
var blockquoteDepth: Int = -1,
|
|
318
|
+
var listDepth: Int = -1,
|
|
319
|
+
var needsBlankLine: Boolean = false,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
/** Accumulates heading content across multiple span segments. */
|
|
323
|
+
private class HeadingAccumulator {
|
|
324
|
+
private var level: Int? = null
|
|
325
|
+
private val content = StringBuilder()
|
|
326
|
+
|
|
327
|
+
fun accumulate(
|
|
328
|
+
newLevel: Int,
|
|
329
|
+
text: String,
|
|
330
|
+
result: StringBuilder,
|
|
331
|
+
state: ExtractionState,
|
|
332
|
+
) {
|
|
333
|
+
if (level != newLevel) {
|
|
334
|
+
flush(result, state)
|
|
335
|
+
level = newLevel
|
|
336
|
+
}
|
|
337
|
+
content.append(text.trim('\n'))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fun flush(
|
|
341
|
+
result: StringBuilder,
|
|
342
|
+
state: ExtractionState,
|
|
343
|
+
) {
|
|
344
|
+
val currentLevel = level ?: return
|
|
345
|
+
if (content.isEmpty()) return
|
|
346
|
+
|
|
347
|
+
result.ensureBlankLine()
|
|
348
|
+
result.append("#".repeat(currentLevel))
|
|
349
|
+
result.append(" ")
|
|
350
|
+
result.append(content.toString().trim())
|
|
351
|
+
result.append("\n")
|
|
352
|
+
|
|
353
|
+
level = null
|
|
354
|
+
content.clear()
|
|
355
|
+
state.needsBlankLine = true
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private fun StringBuilder.ensureBlankLine() {
|
|
360
|
+
if (isEmpty() || endsWith("\n\n")) return
|
|
361
|
+
append(if (endsWith("\n")) "\n" else "\n\n")
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private fun StringBuilder.isAtLineStart(): Boolean = isEmpty() || endsWith("\n")
|
|
365
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
package com.swmansion.enriched.markdown.utils
|
|
2
|
+
|
|
3
|
+
import android.content.ClipData
|
|
4
|
+
import android.content.ClipboardManager
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.text.Spannable
|
|
7
|
+
import android.view.ActionMode
|
|
8
|
+
import android.view.Menu
|
|
9
|
+
import android.view.MenuItem
|
|
10
|
+
import android.widget.TextView
|
|
11
|
+
import com.swmansion.enriched.markdown.EnrichedMarkdownText
|
|
12
|
+
import com.swmansion.enriched.markdown.spans.ImageSpan
|
|
13
|
+
|
|
14
|
+
private const val MENU_ITEM_COPY_MARKDOWN = 1000
|
|
15
|
+
private const val MENU_ITEM_COPY_IMAGE_URL = 1001
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates an ActionMode.Callback that adds custom copy options and
|
|
19
|
+
* overrides the default "Copy" action to include HTML for rich text support.
|
|
20
|
+
*/
|
|
21
|
+
fun createSelectionActionModeCallback(textView: TextView): ActionMode.Callback =
|
|
22
|
+
object : ActionMode.Callback {
|
|
23
|
+
override fun onCreateActionMode(
|
|
24
|
+
mode: ActionMode?,
|
|
25
|
+
menu: Menu?,
|
|
26
|
+
): Boolean = true
|
|
27
|
+
|
|
28
|
+
override fun onPrepareActionMode(
|
|
29
|
+
mode: ActionMode?,
|
|
30
|
+
menu: Menu?,
|
|
31
|
+
): Boolean {
|
|
32
|
+
if (menu == null) return false
|
|
33
|
+
|
|
34
|
+
menu.removeItem(MENU_ITEM_COPY_MARKDOWN)
|
|
35
|
+
menu.removeItem(MENU_ITEM_COPY_IMAGE_URL)
|
|
36
|
+
|
|
37
|
+
if (textView.selectionStart >= 0 && textView.selectionEnd > textView.selectionStart) {
|
|
38
|
+
menu.add(Menu.NONE, MENU_ITEM_COPY_MARKDOWN, Menu.NONE, "Copy as Markdown")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
val imageUrls = textView.getImageUrlsInSelection()
|
|
42
|
+
if (imageUrls.isNotEmpty()) {
|
|
43
|
+
val title =
|
|
44
|
+
if (imageUrls.size == 1) {
|
|
45
|
+
"Copy Image URL"
|
|
46
|
+
} else {
|
|
47
|
+
"Copy ${imageUrls.size} Image URLs"
|
|
48
|
+
}
|
|
49
|
+
menu.add(Menu.NONE, MENU_ITEM_COPY_IMAGE_URL, Menu.NONE, title)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override fun onActionItemClicked(
|
|
56
|
+
mode: ActionMode?,
|
|
57
|
+
item: MenuItem?,
|
|
58
|
+
): Boolean {
|
|
59
|
+
when (item?.itemId) {
|
|
60
|
+
android.R.id.copy -> {
|
|
61
|
+
textView.copyWithHTML()
|
|
62
|
+
mode?.finish()
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
MENU_ITEM_COPY_MARKDOWN -> {
|
|
67
|
+
textView.copyMarkdownToClipboard()
|
|
68
|
+
mode?.finish()
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
MENU_ITEM_COPY_IMAGE_URL -> {
|
|
73
|
+
textView.copyImageUrlsToClipboard()
|
|
74
|
+
mode?.finish()
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override fun onDestroyActionMode(mode: ActionMode?) {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Copies selection as both plain text and HTML with inline styles. */
|
|
85
|
+
private fun TextView.copyWithHTML() {
|
|
86
|
+
val start = selectionStart
|
|
87
|
+
val end = selectionEnd
|
|
88
|
+
if (start < 0 || end < 0 || start >= end) return
|
|
89
|
+
|
|
90
|
+
val spannable = text as? Spannable ?: return
|
|
91
|
+
val selectedText = spannable.subSequence(start, end)
|
|
92
|
+
val plainText = selectedText.toString()
|
|
93
|
+
|
|
94
|
+
val enrichedMarkdownText = this as? EnrichedMarkdownText
|
|
95
|
+
val styleConfig = enrichedMarkdownText?.markdownStyle
|
|
96
|
+
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
97
|
+
|
|
98
|
+
if (styleConfig != null && selectedText is Spannable) {
|
|
99
|
+
// Density values convert device pixels back to CSS pixels
|
|
100
|
+
val displayMetrics = context.resources.displayMetrics
|
|
101
|
+
val html =
|
|
102
|
+
HTMLGenerator.generateHTML(
|
|
103
|
+
selectedText,
|
|
104
|
+
styleConfig,
|
|
105
|
+
displayMetrics.scaledDensity,
|
|
106
|
+
displayMetrics.density,
|
|
107
|
+
)
|
|
108
|
+
clipboard.setPrimaryClip(ClipData.newHtmlText("EnrichedMarkdown", plainText, html))
|
|
109
|
+
} else {
|
|
110
|
+
clipboard.setPrimaryClip(ClipData.newPlainText("Text", plainText))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private fun TextView.copyMarkdownToClipboard() {
|
|
115
|
+
val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: return
|
|
116
|
+
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
117
|
+
clipboard.setPrimaryClip(ClipData.newPlainText("Markdown", markdown))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Returns remote image URLs (http/https only) from the current selection. */
|
|
121
|
+
private fun TextView.getImageUrlsInSelection(): List<String> {
|
|
122
|
+
val start = selectionStart
|
|
123
|
+
val end = selectionEnd
|
|
124
|
+
if (start < 0 || end < 0 || start >= end) return emptyList()
|
|
125
|
+
|
|
126
|
+
val spannable = text as? Spannable ?: return emptyList()
|
|
127
|
+
return spannable
|
|
128
|
+
.getSpans(start, end, ImageSpan::class.java)
|
|
129
|
+
.mapNotNull { it.imageUrl }
|
|
130
|
+
.filter { it.startsWith("http://") || it.startsWith("https://") }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun TextView.copyImageUrlsToClipboard() {
|
|
134
|
+
val urls = getImageUrlsInSelection()
|
|
135
|
+
if (urls.isEmpty()) return
|
|
136
|
+
|
|
137
|
+
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
138
|
+
clipboard.setPrimaryClip(ClipData.newPlainText("Image URLs", urls.joinToString("\n")))
|
|
139
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
package com.swmansion.enriched.markdown.utils
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Typeface
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.text.SpannableString
|
|
7
|
+
import android.text.SpannableStringBuilder
|
|
8
|
+
import android.text.TextPaint
|
|
9
|
+
import com.facebook.react.common.ReactConstants
|
|
10
|
+
import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles
|
|
11
|
+
import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
|
|
12
|
+
import com.swmansion.enriched.markdown.parser.MarkdownASTNode
|
|
13
|
+
import com.swmansion.enriched.markdown.renderer.BlockStyle
|
|
14
|
+
import com.swmansion.enriched.markdown.spans.LineHeightSpan
|
|
15
|
+
import com.swmansion.enriched.markdown.spans.MarginBottomSpan
|
|
16
|
+
import com.swmansion.enriched.markdown.styles.ParagraphStyle
|
|
17
|
+
import com.swmansion.enriched.markdown.styles.StyleConfig
|
|
18
|
+
import android.text.style.LineHeightSpan as AndroidLineHeightSpan
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Standard span flags for exclusive span boundaries.
|
|
26
|
+
* Spans with these flags do not expand when text is inserted at their boundaries.
|
|
27
|
+
*/
|
|
28
|
+
const val SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// TextPaint Extensions
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Applies a color to TextPaint while preserving priority colors (e.g., link color).
|
|
36
|
+
*/
|
|
37
|
+
fun TextPaint.applyColorPreserving(
|
|
38
|
+
color: Int,
|
|
39
|
+
vararg preserveColors: Int,
|
|
40
|
+
) {
|
|
41
|
+
if (this.color !in preserveColors) {
|
|
42
|
+
this.color = color
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private val typefaceCache = mutableMapOf<String, Typeface>()
|
|
47
|
+
private val fontWeightCache = mutableMapOf<String?, Int>()
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Applies fontFamily and fontWeight from BlockStyle to TextPaint.
|
|
51
|
+
* Uses caching to avoid expensive typeface creation on every call.
|
|
52
|
+
*/
|
|
53
|
+
fun TextPaint.applyBlockStyleFont(
|
|
54
|
+
blockStyle: BlockStyle,
|
|
55
|
+
context: Context,
|
|
56
|
+
) {
|
|
57
|
+
val cacheKey = "${blockStyle.fontFamily}|${blockStyle.fontWeight}"
|
|
58
|
+
|
|
59
|
+
val cachedTypeface = typefaceCache[cacheKey]
|
|
60
|
+
if (cachedTypeface != null) {
|
|
61
|
+
this.typeface = cachedTypeface
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
val fontWeight =
|
|
66
|
+
fontWeightCache.getOrPut(blockStyle.fontWeight) {
|
|
67
|
+
parseFontWeight(blockStyle.fontWeight)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pass null as base typeface - this matches React Native Text behavior
|
|
71
|
+
// applyStyles will use ReactFontManager to load custom fonts from assets
|
|
72
|
+
val newTypeface =
|
|
73
|
+
applyStyles(
|
|
74
|
+
null, // Let applyStyles handle font loading from assets
|
|
75
|
+
ReactConstants.UNSET,
|
|
76
|
+
fontWeight,
|
|
77
|
+
blockStyle.fontFamily.takeIf { it.isNotEmpty() },
|
|
78
|
+
context.assets,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
typefaceCache[cacheKey] = newTypeface
|
|
82
|
+
this.typeface = newTypeface
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Paragraph/Node Utilities
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Determines if a paragraph contains only a single block image.
|
|
91
|
+
*/
|
|
92
|
+
fun MarkdownASTNode.containsBlockImage(): Boolean {
|
|
93
|
+
if (type != MarkdownASTNode.NodeType.Paragraph) return false
|
|
94
|
+
val firstChild = children.firstOrNull()
|
|
95
|
+
return firstChild != null && children.size == 1 && firstChild.type == MarkdownASTNode.NodeType.Image
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Determines the appropriate marginBottom for a paragraph.
|
|
100
|
+
* If paragraph contains only a single block-level element (e.g., image), uses that element's marginBottom.
|
|
101
|
+
* Otherwise, uses paragraph's marginBottom.
|
|
102
|
+
*/
|
|
103
|
+
fun getMarginBottomForParagraph(
|
|
104
|
+
paragraph: MarkdownASTNode,
|
|
105
|
+
paragraphStyle: ParagraphStyle,
|
|
106
|
+
style: StyleConfig,
|
|
107
|
+
): Float {
|
|
108
|
+
// If paragraph contains only a single block-level element, use that element's marginBottom
|
|
109
|
+
// Otherwise, use paragraph's marginBottom
|
|
110
|
+
if (paragraph.children.size == 1) {
|
|
111
|
+
// Paragraph has exactly one child
|
|
112
|
+
when (paragraph.children.first().type) {
|
|
113
|
+
MarkdownASTNode.NodeType.Image -> {
|
|
114
|
+
// Image: use image's marginBottom
|
|
115
|
+
return style.imageStyle.marginBottom
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Future: Add other block elements here as they're implemented
|
|
119
|
+
else -> {
|
|
120
|
+
// Not a block element we handle specially
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Default: use paragraph's marginBottom
|
|
126
|
+
return paragraphStyle.marginBottom
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// SpannableStringBuilder Extensions
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Determines if an image should be rendered inline (within text) or as a block element.
|
|
135
|
+
* An image is inline if it's not preceded by a line break or zero-width space.
|
|
136
|
+
*/
|
|
137
|
+
fun SpannableStringBuilder.isInlineImage(): Boolean {
|
|
138
|
+
if (isEmpty()) return false
|
|
139
|
+
val lastChar = last()
|
|
140
|
+
return lastChar != '\n' && lastChar != '\u200B'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// Span Creation Utilities
|
|
145
|
+
// ============================================================================
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Creates a LineHeightSpan appropriate for the current API level.
|
|
149
|
+
*
|
|
150
|
+
* @param lineHeight The desired line height in pixels
|
|
151
|
+
*/
|
|
152
|
+
fun createLineHeightSpan(lineHeight: Float): AndroidLineHeightSpan =
|
|
153
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
154
|
+
AndroidLineHeightSpan.Standard(lineHeight.toInt())
|
|
155
|
+
} else {
|
|
156
|
+
LineHeightSpan(lineHeight)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Applies marginBottom spacing to a block element.
|
|
161
|
+
* Appends a newline and applies MarginBottomSpan if marginBottom > 0.
|
|
162
|
+
*
|
|
163
|
+
* @param builder The SpannableStringBuilder to modify
|
|
164
|
+
* @param start The start position of the block content (before appending newline)
|
|
165
|
+
* @param marginBottom The spacing value to apply after the block
|
|
166
|
+
*/
|
|
167
|
+
fun applyMarginBottom(
|
|
168
|
+
builder: SpannableStringBuilder,
|
|
169
|
+
start: Int,
|
|
170
|
+
marginBottom: Float,
|
|
171
|
+
) {
|
|
172
|
+
builder.append("\n")
|
|
173
|
+
if (marginBottom > 0) {
|
|
174
|
+
builder.setSpan(
|
|
175
|
+
MarginBottomSpan(marginBottom),
|
|
176
|
+
start,
|
|
177
|
+
builder.length, // Includes the newline we just appended
|
|
178
|
+
SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|