react-native-enriched-markdown-scaffold 0.7.4-scaffold.1 → 0.7.4-scaffold.11
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/android/build.gradle +5 -0
- package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerDelegate.java +9 -0
- package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerInterface.java +3 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +10 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +7 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +10 -0
- package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +50 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +6 -4
- package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +38 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +174 -9
- package/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnPasteEvent.kt +24 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockSyntaxSentinel.kt +69 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +71 -12
- package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +8 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt +30 -2
- package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +7 -2
- package/android/src/main/java/com/swmansion/enriched/markdown/input/model/MathChipStyle.kt +3 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +1 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/HeadingSpan.kt +8 -1
- package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +27 -1
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineMathStyle.kt +5 -1
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathFontWeight.kt +25 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathStyle.kt +12 -1
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +7 -7
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/MarkdownStyleParser.kt +10 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/PasteTextUtils.kt +20 -0
- package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/SpannableExtensions.kt +12 -4
- package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +1 -1
- package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +2 -0
- package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathChipSpan.kt +1 -1
- package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlinePlaceholderSpan.kt +7 -3
- package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +8 -5
- package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathTypeface.kt +30 -0
- package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +3 -1
- package/docs/API_REFERENCE.md +43 -0
- package/docs/INPUT.md +87 -0
- package/docs/LATEX_MATH.md +62 -0
- package/docs/STYLES.md +3 -0
- package/ios/EnrichedMarkdown.mm +44 -12
- package/ios/EnrichedMarkdownText.mm +47 -11
- package/ios/attachments/ENRMMathInlineAttachment+macOS.m +5 -1
- package/ios/attachments/ENRMMathInlineAttachment.h +3 -0
- package/ios/attachments/ENRMMathInlineAttachment.m +2 -1
- package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +10 -0
- package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +7 -0
- package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +10 -0
- package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +50 -0
- package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +29 -0
- package/ios/input/ENRMBlockSyntaxSentinel.h +25 -0
- package/ios/input/ENRMBlockSyntaxSentinel.mm +80 -0
- package/ios/input/ENRMInputFormatter.h +4 -0
- package/ios/input/ENRMInputFormatter.mm +2 -0
- package/ios/input/ENRMInputParser.h +5 -0
- package/ios/input/ENRMInputParser.mm +79 -6
- package/ios/input/ENRMInputTextView.mm +11 -1
- package/ios/input/EnrichedMarkdownTextInput.h +5 -0
- package/ios/input/EnrichedMarkdownTextInput.mm +88 -22
- package/ios/input/InputStylePropsUtils.h +13 -0
- package/ios/internals/MeasurementCache.h +1 -0
- package/ios/math/ENRMRaTeXBridge.swift +52 -3
- package/ios/parser/ENRMMarkdownParser.h +8 -0
- package/ios/parser/ENRMMarkdownParser.mm +11 -0
- package/ios/renderer/ENRMMathInlineRenderer.m +3 -0
- package/ios/renderer/ListItemRenderer.m +8 -5
- package/ios/styles/StyleConfig.h +6 -0
- package/ios/styles/StyleConfig.mm +36 -0
- package/ios/utils/ENRMMathFontWeight.h +18 -0
- package/ios/utils/StylePropsUtils.h +21 -0
- package/ios/views/ENRMMathContainerView.m +3 -1
- package/lib/module/EnrichedMarkdownNativeComponent.ts +3 -0
- package/lib/module/EnrichedMarkdownTextInput.js +17 -0
- package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
- package/lib/module/EnrichedMarkdownTextInputNativeComponent.ts +34 -0
- package/lib/module/EnrichedMarkdownTextNativeComponent.ts +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/normalizeMarkdownStyle.js +5 -2
- package/lib/module/normalizeMarkdownStyle.js.map +1 -1
- package/lib/module/normalizeMarkdownStyle.web.js +5 -2
- package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
- package/lib/module/normalizeMarkdownTextInputStyle.js +7 -3
- package/lib/module/normalizeMarkdownTextInputStyle.js.map +1 -1
- package/lib/module/web/globalStyles.js +8 -1
- package/lib/module/web/globalStyles.js.map +1 -1
- package/lib/module/web/styles.js +3 -1
- package/lib/module/web/styles.js.map +1 -1
- package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +3 -0
- package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +26 -1
- package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
- package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts +28 -0
- package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +3 -0
- package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
- package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
- package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts +1 -0
- package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts.map +1 -1
- package/lib/typescript/src/types/MarkdownStyle.d.ts +32 -0
- package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
- package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +3 -0
- package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
- package/lib/typescript/src/types/events.d.ts +15 -0
- package/lib/typescript/src/types/events.d.ts.map +1 -1
- package/lib/typescript/src/web/styles.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/EnrichedMarkdownNativeComponent.ts +3 -0
- package/src/EnrichedMarkdownTextInput.tsx +41 -0
- package/src/EnrichedMarkdownTextInputNativeComponent.ts +34 -0
- package/src/EnrichedMarkdownTextNativeComponent.ts +3 -0
- package/src/index.tsx +1 -0
- package/src/normalizeMarkdownStyle.ts +6 -1
- package/src/normalizeMarkdownStyle.web.ts +6 -1
- package/src/normalizeMarkdownTextInputStyle.ts +5 -0
- package/src/types/MarkdownStyle.ts +32 -0
- package/src/types/MarkdownStyleInternal.ts +3 -0
- package/src/types/events.ts +16 -0
- package/src/web/globalStyles.ts +7 -0
- package/src/web/styles.ts +5 -1
package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt
CHANGED
|
@@ -20,6 +20,9 @@ import android.view.inputmethod.EditorInfo
|
|
|
20
20
|
import android.view.inputmethod.InputConnection
|
|
21
21
|
import android.view.inputmethod.InputMethodManager
|
|
22
22
|
import androidx.appcompat.widget.AppCompatEditText
|
|
23
|
+
import androidx.core.view.ContentInfoCompat
|
|
24
|
+
import androidx.core.view.OnReceiveContentListener
|
|
25
|
+
import androidx.core.view.ViewCompat
|
|
23
26
|
import com.facebook.react.common.ReactConstants
|
|
24
27
|
import com.facebook.react.uimanager.BackgroundStyleApplicator
|
|
25
28
|
import com.facebook.react.uimanager.PixelUtil
|
|
@@ -44,6 +47,7 @@ import com.swmansion.enriched.markdown.input.model.MathChipStyle
|
|
|
44
47
|
import com.swmansion.enriched.markdown.input.model.StyleType
|
|
45
48
|
import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu
|
|
46
49
|
import com.swmansion.enriched.markdown.utils.input.AutoCapitalizeUtils
|
|
50
|
+
import com.swmansion.enriched.markdown.utils.input.PasteTextUtils
|
|
47
51
|
import kotlin.math.ceil
|
|
48
52
|
|
|
49
53
|
private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r' || this == '\u0085' || this == '\u2028' || this == '\u2029'
|
|
@@ -97,6 +101,36 @@ class EnrichedMarkdownTextInputView(
|
|
|
97
101
|
private var detectScrollMovement = false
|
|
98
102
|
var scrollEnabled: Boolean = true
|
|
99
103
|
|
|
104
|
+
// When on, a paste is reported to JS instead of inserted. Native never touches
|
|
105
|
+
// the buffer here (see resolvePaste): if JS dies mid-handler the paste is a
|
|
106
|
+
// visible no-op rather than a selection that silently vanished.
|
|
107
|
+
//
|
|
108
|
+
// The listener is installed only while the prop is on. Registering it
|
|
109
|
+
// unconditionally would route every consumer's paste through AppCompat's
|
|
110
|
+
// receive-content path even with the feature off, which is a behavior change
|
|
111
|
+
// nobody asked for.
|
|
112
|
+
var interceptPaste: Boolean = false
|
|
113
|
+
set(value) {
|
|
114
|
+
if (field == value) return
|
|
115
|
+
field = value
|
|
116
|
+
ViewCompat.setOnReceiveContentListener(
|
|
117
|
+
this,
|
|
118
|
+
if (value) PASTE_MIME_TYPES else null,
|
|
119
|
+
if (value) receiveContentListener else null,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The markdown last handed over by JS, kept so `blockSyntax` can re-parse it. @ReactProp
|
|
124
|
+
// setters fire in undefined order, so `defaultValue` regularly lands before the flag does.
|
|
125
|
+
private var lastMarkdownFromJS: String? = null
|
|
126
|
+
|
|
127
|
+
var blockSyntax: Boolean = true
|
|
128
|
+
set(value) {
|
|
129
|
+
if (field == value) return
|
|
130
|
+
field = value
|
|
131
|
+
lastMarkdownFromJS?.let { setValueFromJS(it) }
|
|
132
|
+
}
|
|
133
|
+
|
|
100
134
|
private var mentionIndicators: LinkedHashSet<String> = linkedSetOf()
|
|
101
135
|
private var activeMentionIndicator: String? = null
|
|
102
136
|
private var activeMentionStart = -1
|
|
@@ -477,6 +511,98 @@ class EnrichedMarkdownTextInputView(
|
|
|
477
511
|
clipboard.setPrimaryClip(ClipData.newPlainText(null, content))
|
|
478
512
|
}
|
|
479
513
|
|
|
514
|
+
// Paste arrives by two different routes and only the first one is a menu.
|
|
515
|
+
// `onTextContextMenuItem` covers the context menu and the hardware Ctrl+V /
|
|
516
|
+
// KEYCODE_PASTE shortcuts; drag-and-drop, autofill and IME `commitContent`
|
|
517
|
+
// reach the view through `onReceiveContent` instead. Hooking only one of them
|
|
518
|
+
// leaves the other pasting straight into the buffer with nobody intercepting.
|
|
519
|
+
override fun onTextContextMenuItem(id: Int): Boolean {
|
|
520
|
+
val isPaste = id == android.R.id.paste || id == android.R.id.pasteAsPlainText
|
|
521
|
+
if (isPaste && interceptPaste) {
|
|
522
|
+
val text = readClipboardText()
|
|
523
|
+
if (text.isNotEmpty()) {
|
|
524
|
+
eventEmitter.emitPaste(text, "")
|
|
525
|
+
// Stops super, which is what keeps this single-emit: AppCompat routes
|
|
526
|
+
// its own paste handling through the receive-content listener below.
|
|
527
|
+
return true
|
|
528
|
+
}
|
|
529
|
+
// Nothing textual in the clip: hand it to the platform, matching what the
|
|
530
|
+
// listener does with the items it does not claim. Both routes then decide
|
|
531
|
+
// from the same rule, so a clip never behaves differently depending on
|
|
532
|
+
// whether it came from the menu or from Ctrl+V.
|
|
533
|
+
}
|
|
534
|
+
return super.onTextContextMenuItem(id)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private val receiveContentListener =
|
|
538
|
+
OnReceiveContentListener { _, payload ->
|
|
539
|
+
// The listener also sees autofill, drag-and-drop, IME commitContent and
|
|
540
|
+
// PROCESS_TEXT. Without this filter an autofill would turn into a paste
|
|
541
|
+
// event — and a consumer would turn that into an attachment chip.
|
|
542
|
+
if (!interceptPaste || payload.source != ContentInfoCompat.SOURCE_CLIPBOARD) {
|
|
543
|
+
return@OnReceiveContentListener payload
|
|
544
|
+
}
|
|
545
|
+
val parts = payload.partition { it.text != null }
|
|
546
|
+
parts.first?.let { accepted ->
|
|
547
|
+
val clip = accepted.clip
|
|
548
|
+
val text = PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
|
|
549
|
+
if (text.isNotEmpty()) {
|
|
550
|
+
eventEmitter.emitPaste(text, "")
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Hand back only what we did not claim, so the buffer stays untouched.
|
|
554
|
+
parts.second
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private fun readClipboardText(): String {
|
|
558
|
+
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return ""
|
|
559
|
+
val clip = clipboard.primaryClip ?: return ""
|
|
560
|
+
// `text`, not `coerceToText`: the listener can only claim items that already
|
|
561
|
+
// carry text, and the two routes have to agree on what counts as a paste.
|
|
562
|
+
return PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Completes a paste reported through `onPaste`. `markdown` is accepted for
|
|
567
|
+
* parity with iOS but ignored here: Android's clipboard is plain text only
|
|
568
|
+
* (see `copyToClipboard`), so there is no flavor to restore.
|
|
569
|
+
*
|
|
570
|
+
* Resolving with an empty string and an empty selection is a zero-length edit
|
|
571
|
+
* — it still emits, which keeps the JS contract identical on both platforms.
|
|
572
|
+
*/
|
|
573
|
+
fun resolvePaste(
|
|
574
|
+
pastedText: String,
|
|
575
|
+
@Suppress("UNUSED_PARAMETER") markdown: String,
|
|
576
|
+
) {
|
|
577
|
+
val editable = text ?: return
|
|
578
|
+
val selStart = selectionStart.coerceAtLeast(0)
|
|
579
|
+
val selEnd = selectionEnd.coerceAtLeast(selStart)
|
|
580
|
+
val safeStart = selStart.coerceAtMost(editable.length)
|
|
581
|
+
val safeEnd = selEnd.coerceAtMost(editable.length)
|
|
582
|
+
|
|
583
|
+
isProcessingTextChange = true
|
|
584
|
+
try {
|
|
585
|
+
editable.replace(safeStart, safeEnd, pastedText)
|
|
586
|
+
formattingStore.adjustForEdit(safeStart, safeEnd - safeStart, pastedText.length)
|
|
587
|
+
lastProcessedText = editable.toString()
|
|
588
|
+
setSelection(safeStart + pastedText.length)
|
|
589
|
+
} finally {
|
|
590
|
+
isProcessingTextChange = false
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
applyFormatting()
|
|
594
|
+
// Parity with the un-intercepted paste, which reached the detectors through
|
|
595
|
+
// the text watcher: a pasted URL still autolinks.
|
|
596
|
+
detectorPipeline.processTextChange(editable, editable.toString(), safeStart, pastedText.length)
|
|
597
|
+
invalidate()
|
|
598
|
+
requestLayout()
|
|
599
|
+
eventEmitter.emitChangeText()
|
|
600
|
+
if (emitMarkdown) eventEmitter.emitChangeMarkdown()
|
|
601
|
+
eventEmitter.emitSelection(selectionStart, selectionEnd)
|
|
602
|
+
eventEmitter.emitState()
|
|
603
|
+
eventEmitter.emitCaretRectChangeIfNeeded()
|
|
604
|
+
}
|
|
605
|
+
|
|
480
606
|
fun setLinkForSelection(url: String) {
|
|
481
607
|
val selStart = selectionStart
|
|
482
608
|
val selEnd = selectionEnd
|
|
@@ -634,7 +760,8 @@ class EnrichedMarkdownTextInputView(
|
|
|
634
760
|
}
|
|
635
761
|
|
|
636
762
|
fun setValueFromJS(markdown: String) {
|
|
637
|
-
|
|
763
|
+
lastMarkdownFromJS = markdown
|
|
764
|
+
val parsed = InputParser.parseToPlainTextAndRanges(markdown, blockSyntax)
|
|
638
765
|
blockEmitting = true
|
|
639
766
|
try {
|
|
640
767
|
runAsATransaction {
|
|
@@ -708,8 +835,16 @@ class EnrichedMarkdownTextInputView(
|
|
|
708
835
|
}
|
|
709
836
|
invalidate()
|
|
710
837
|
requestLayout()
|
|
838
|
+
// Chips only reach the text here when `defaultValue` landed before `markdownStyle`
|
|
839
|
+
// (@ReactProp order is undefined). requestLayout() cannot change a height Fabric owns —
|
|
840
|
+
// the shadow tree has to re-measure text that now carries the spans.
|
|
841
|
+
layoutManager.invalidateLayout()
|
|
711
842
|
} else if (mathStyleChanged) {
|
|
712
843
|
refreshAllMathChips()
|
|
844
|
+
// A math font size / padding change resizes every chip, so the measured height moves.
|
|
845
|
+
// Colour-only changes end up here too; store() then measures the same size and skips
|
|
846
|
+
// the state update.
|
|
847
|
+
layoutManager.invalidateLayout()
|
|
713
848
|
}
|
|
714
849
|
return changed
|
|
715
850
|
}
|
|
@@ -848,18 +983,36 @@ class EnrichedMarkdownTextInputView(
|
|
|
848
983
|
return true
|
|
849
984
|
}
|
|
850
985
|
|
|
851
|
-
|
|
986
|
+
/** Chip glyph rect in [Layout] coordinates — no padding, no scroll offset. */
|
|
987
|
+
private fun chipBoundsInLayout(
|
|
852
988
|
layout: Layout,
|
|
853
989
|
start: Int,
|
|
854
990
|
): Rect {
|
|
855
991
|
val line = layout.getLineForOffset(start)
|
|
856
|
-
val xStart =
|
|
857
|
-
val xEnd =
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
992
|
+
val xStart = layout.getPrimaryHorizontal(start).toInt()
|
|
993
|
+
val xEnd = layout.getPrimaryHorizontal(start + 1).toInt()
|
|
994
|
+
// Ordered, not raw start/end: in an RTL run xStart > xEnd, which would make an
|
|
995
|
+
// inverted Rect that contains() always rejects.
|
|
996
|
+
val left = xStart.coerceAtMost(xEnd)
|
|
997
|
+
var right = xStart.coerceAtLeast(xEnd)
|
|
998
|
+
if (right <= left) {
|
|
999
|
+
// A chip that is the last character has start + 1 == length, where some layouts
|
|
1000
|
+
// report the same horizontal for both offsets. Fall back to the line's content edge
|
|
1001
|
+
// so the rect keeps a real width instead of collapsing.
|
|
1002
|
+
right = layout.getLineRight(line).toInt()
|
|
1003
|
+
}
|
|
1004
|
+
return Rect(left, layout.getLineTop(line), right, layout.getLineBottom(line))
|
|
861
1005
|
}
|
|
862
1006
|
|
|
1007
|
+
/** Chip glyph rect in view coordinates, for reporting to JS. */
|
|
1008
|
+
private fun computeChipBounds(
|
|
1009
|
+
layout: Layout,
|
|
1010
|
+
start: Int,
|
|
1011
|
+
): Rect =
|
|
1012
|
+
chipBoundsInLayout(layout, start).apply {
|
|
1013
|
+
offset(totalPaddingLeft - scrollX, totalPaddingTop - scrollY)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
863
1016
|
fun mathChipPositionAtTouch(
|
|
864
1017
|
x: Float,
|
|
865
1018
|
y: Float,
|
|
@@ -869,13 +1022,21 @@ class EnrichedMarkdownTextInputView(
|
|
|
869
1022
|
val layoutNow = layout ?: return -1
|
|
870
1023
|
val adjustedX = (x - totalPaddingLeft + scrollX).toInt()
|
|
871
1024
|
val adjustedY = (y - totalPaddingTop + scrollY).toInt()
|
|
872
|
-
|
|
1025
|
+
// getLineForVertical and getOffsetForHorizontal both clamp into the laid-out text, so
|
|
1026
|
+
// a touch in the padding or in the blank space below the last line resolves onto the
|
|
1027
|
+
// nearest character. Bound the touch to the text box, then require it to land inside
|
|
1028
|
+
// the chip's own rect — otherwise a tap beside a chip reads as a tap on it.
|
|
1029
|
+
if (adjustedY < 0 || adjustedY > layoutNow.height) return -1
|
|
873
1030
|
val line = layoutNow.getLineForVertical(adjustedY)
|
|
874
1031
|
val offset = layoutNow.getOffsetForHorizontal(line, adjustedX.toFloat())
|
|
875
|
-
if (offset < 0
|
|
1032
|
+
if (offset < 0) return -1
|
|
876
1033
|
val candidates = intArrayOf(offset, offset - 1).filter { it in 0 until editable.length }
|
|
877
1034
|
for (candidate in candidates) {
|
|
878
1035
|
val range = controller.chipRangeAt(editable, candidate) ?: continue
|
|
1036
|
+
// An empty rect means the layout could not give us the chip's extent; accept the
|
|
1037
|
+
// candidate then, rather than making the chip untappable.
|
|
1038
|
+
val bounds = chipBoundsInLayout(layoutNow, range.first)
|
|
1039
|
+
if (!bounds.isEmpty && !bounds.contains(adjustedX, adjustedY)) continue
|
|
879
1040
|
return range.first
|
|
880
1041
|
}
|
|
881
1042
|
return -1
|
|
@@ -1109,6 +1270,10 @@ class EnrichedMarkdownTextInputView(
|
|
|
1109
1270
|
.replace("(", "%28")
|
|
1110
1271
|
.replace(")", "%29")
|
|
1111
1272
|
|
|
1273
|
+
private companion object {
|
|
1274
|
+
private val PASTE_MIME_TYPES = arrayOf("text/*")
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1112
1277
|
private data class MentionCandidate(
|
|
1113
1278
|
val indicator: String,
|
|
1114
1279
|
val start: Int,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
package com.swmansion.enriched.markdown.input.events
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.WritableMap
|
|
5
|
+
import com.facebook.react.uimanager.events.Event
|
|
6
|
+
|
|
7
|
+
class OnPasteEvent(
|
|
8
|
+
surfaceId: Int,
|
|
9
|
+
viewId: Int,
|
|
10
|
+
private val text: String,
|
|
11
|
+
private val markdown: String,
|
|
12
|
+
) : Event<OnPasteEvent>(surfaceId, viewId) {
|
|
13
|
+
override fun getEventName(): String = EVENT_NAME
|
|
14
|
+
|
|
15
|
+
override fun getEventData(): WritableMap =
|
|
16
|
+
Arguments.createMap().apply {
|
|
17
|
+
putString("text", text)
|
|
18
|
+
putString("markdown", markdown)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
companion object {
|
|
22
|
+
const val EVENT_NAME = "onPaste"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package com.swmansion.enriched.markdown.input.formatting
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hides block-level markdown structure from md4c so a document parses as a single
|
|
5
|
+
* `Paragraph`. Used by `blockSyntax = false` on `EnrichedMarkdownTextInput`.
|
|
6
|
+
*
|
|
7
|
+
* Every newline is swapped for a vertical tab (`0x0B`) and one more is prepended.
|
|
8
|
+
* md4c treats VT as ordinary whitespace, never as a line break, so no line ever
|
|
9
|
+
* starts at a position where a list marker, `#`, `>` or an indented code block can
|
|
10
|
+
* be recognised — including line 1, which is what the leading sentinel is for.
|
|
11
|
+
* Inline syntax (`$...$`, `**...**`, links) still parses normally.
|
|
12
|
+
*
|
|
13
|
+
* The transform is 1:1 in characters *and* in UTF-8 bytes (VT and `\n` are both one
|
|
14
|
+
* of each), so offsets coming back out of the parser only need a flat -1 shift for
|
|
15
|
+
* the leading sentinel. iOS relies on the same property to keep its byte-to-UTF16
|
|
16
|
+
* map valid.
|
|
17
|
+
*/
|
|
18
|
+
internal object BlockSyntaxSentinel {
|
|
19
|
+
const val SENTINEL = '\u000B'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `\r\n` / `\r` collapse to `\n` (md4c normalizes line endings this way regardless,
|
|
23
|
+
* so this is pre-existing lossiness, not a new one), and any `0x0B` already present
|
|
24
|
+
* is dropped — leaving it in would make [decode] silently turn it into a newline.
|
|
25
|
+
*/
|
|
26
|
+
fun canonicalize(markdown: String): String {
|
|
27
|
+
val out = StringBuilder(markdown.length)
|
|
28
|
+
var i = 0
|
|
29
|
+
while (i < markdown.length) {
|
|
30
|
+
when (markdown[i]) {
|
|
31
|
+
'\r' -> {
|
|
32
|
+
out.append('\n')
|
|
33
|
+
if (i + 1 < markdown.length && markdown[i + 1] == '\n') i++
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
SENTINEL -> {}
|
|
37
|
+
|
|
38
|
+
else -> {
|
|
39
|
+
out.append(markdown[i])
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
i++
|
|
43
|
+
}
|
|
44
|
+
return out.toString()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Prepends one sentinel and replaces every `\n` with one. Length grows by exactly 1. */
|
|
48
|
+
fun encode(canonical: String): String {
|
|
49
|
+
val out = StringBuilder(canonical.length + 1)
|
|
50
|
+
out.append(SENTINEL)
|
|
51
|
+
for (c in canonical) out.append(if (c == '\n') SENTINEL else c)
|
|
52
|
+
return out.toString()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reverses [encode] on the parser's plain text. Returns `null` when the leading
|
|
57
|
+
* sentinel is missing — the caller then falls back to the canonical source with no
|
|
58
|
+
* ranges rather than emitting text shifted by an offset that no longer holds.
|
|
59
|
+
*/
|
|
60
|
+
fun decode(plainText: String): String? {
|
|
61
|
+
if (plainText.isEmpty() || plainText[0] != SENTINEL) return null
|
|
62
|
+
val out = StringBuilder(plainText.length - 1)
|
|
63
|
+
for (i in 1 until plainText.length) {
|
|
64
|
+
val c = plainText[i]
|
|
65
|
+
out.append(if (c == SENTINEL) '\n' else c)
|
|
66
|
+
}
|
|
67
|
+
return out.toString()
|
|
68
|
+
}
|
|
69
|
+
}
|
package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt
CHANGED
|
@@ -22,19 +22,73 @@ object InputParser {
|
|
|
22
22
|
// A source paragraph break: a newline followed by one or more blank lines.
|
|
23
23
|
private val PARAGRAPH_BREAK_RE = Regex("\\n[ \\t]*(?:\\n[ \\t]*)+")
|
|
24
24
|
|
|
25
|
-
fun parseToPlainTextAndRanges(
|
|
25
|
+
fun parseToPlainTextAndRanges(
|
|
26
|
+
markdown: String,
|
|
27
|
+
blockSyntax: Boolean = true,
|
|
28
|
+
): ParseResult {
|
|
26
29
|
if (markdown.isEmpty()) {
|
|
27
30
|
return ParseResult("", emptyList())
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
if (!blockSyntax) {
|
|
34
|
+
return parseWithoutBlockSyntax(markdown)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parseCompleted(InputRemend.complete(markdown), markdown, blockSeparators = true)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parses with every block construct hidden behind [BlockSyntaxSentinel], so the whole
|
|
42
|
+
* document lands in one `Paragraph` and list markers, `#`, `>` and friends stay in the
|
|
43
|
+
* text verbatim. `InputRemend` is skipped as well: its speculative closing delimiters
|
|
44
|
+
* are the other source of characters the serializer cannot replay.
|
|
45
|
+
*/
|
|
46
|
+
private fun parseWithoutBlockSyntax(markdown: String): ParseResult {
|
|
47
|
+
val canonical = BlockSyntaxSentinel.canonicalize(markdown)
|
|
48
|
+
if (canonical.isEmpty()) {
|
|
49
|
+
return ParseResult("", emptyList())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// No block separators: the encoded text has no newline for md4c to drop, so every
|
|
53
|
+
// newline the replay would add is spurious. `$$...$$` is the case that proves it —
|
|
54
|
+
// md4c reports display math as a top-level block even mid-line under the sentinel.
|
|
55
|
+
val parsed = parseCompleted(BlockSyntaxSentinel.encode(canonical), canonical, blockSeparators = false)
|
|
56
|
+
val decoded =
|
|
57
|
+
BlockSyntaxSentinel.decode(parsed.plainText)
|
|
58
|
+
?: return ParseResult(canonical, emptyList())
|
|
59
|
+
|
|
60
|
+
// Only the leading sentinel is removed, so every offset shifts by exactly one.
|
|
61
|
+
val ranges =
|
|
62
|
+
parsed.formattingRanges.mapNotNull { range ->
|
|
63
|
+
val start = range.start - 1
|
|
64
|
+
val end = range.end - 1
|
|
65
|
+
if (start < 0 || end > decoded.length || end <= start) {
|
|
66
|
+
null
|
|
67
|
+
} else {
|
|
68
|
+
FormattingRange(range.type, start, end, range.url)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
val atoms =
|
|
72
|
+
parsed.mathAtoms.mapNotNull { atom ->
|
|
73
|
+
val position = atom.position - 1
|
|
74
|
+
if (position < 0 || position >= decoded.length) null else atom.copy(position = position)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return ParseResult(decoded, ranges, atoms)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private fun parseCompleted(
|
|
81
|
+
completed: String,
|
|
82
|
+
fallback: String,
|
|
83
|
+
blockSeparators: Boolean,
|
|
84
|
+
): ParseResult {
|
|
31
85
|
val flags =
|
|
32
86
|
Md4cFlags(
|
|
33
87
|
underline = true,
|
|
34
88
|
latexMath = FeatureFlags.IS_MATH_ENABLED,
|
|
35
89
|
permissiveAutolinks = false,
|
|
36
90
|
)
|
|
37
|
-
val ast = Parser.shared.parseMarkdown(completed, flags) ?: return ParseResult(
|
|
91
|
+
val ast = Parser.shared.parseMarkdown(completed, flags) ?: return ParseResult(fallback, emptyList())
|
|
38
92
|
|
|
39
93
|
val plainText = StringBuilder()
|
|
40
94
|
val ranges = mutableListOf<FormattingRange>()
|
|
@@ -43,14 +97,18 @@ object InputParser {
|
|
|
43
97
|
// md4c collapses any blank-line run into a single break, unlike iOS which keeps them.
|
|
44
98
|
// Re-read the real runs so each break replays its original number of newlines.
|
|
45
99
|
val blankRuns =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
100
|
+
if (blockSeparators) {
|
|
101
|
+
ArrayDeque(
|
|
102
|
+
PARAGRAPH_BREAK_RE
|
|
103
|
+
.findAll(completed.trim())
|
|
104
|
+
.map { match -> match.value.count { it == '\n' } }
|
|
105
|
+
.toList(),
|
|
106
|
+
)
|
|
107
|
+
} else {
|
|
108
|
+
ArrayDeque()
|
|
109
|
+
}
|
|
52
110
|
|
|
53
|
-
walkNode(ast, plainText, ranges, mathAtoms, ArrayDeque(), blankRuns)
|
|
111
|
+
walkNode(ast, plainText, ranges, mathAtoms, ArrayDeque(), blankRuns, blockSeparators)
|
|
54
112
|
|
|
55
113
|
return ParseResult(plainText.toString(), ranges, mathAtoms)
|
|
56
114
|
}
|
|
@@ -62,6 +120,7 @@ object InputParser {
|
|
|
62
120
|
mathAtoms: MutableList<MathAtom>,
|
|
63
121
|
activeStyles: ArrayDeque<ActiveStyle>,
|
|
64
122
|
blankRuns: ArrayDeque<Int>,
|
|
123
|
+
blockSeparators: Boolean,
|
|
65
124
|
) {
|
|
66
125
|
if (node.type == NodeType.LatexMathInline || node.type == NodeType.LatexMathDisplay) {
|
|
67
126
|
val latex = extractLatex(node)
|
|
@@ -92,10 +151,10 @@ object InputParser {
|
|
|
92
151
|
|
|
93
152
|
for ((index, child) in node.children.withIndex()) {
|
|
94
153
|
// Keep the source's blank lines between top-level blocks (md4c drops them, iOS keeps them).
|
|
95
|
-
if (index > 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) {
|
|
154
|
+
if (blockSeparators && index > 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) {
|
|
96
155
|
plainText.append("\n".repeat(blankRuns.removeFirstOrNull() ?: 2))
|
|
97
156
|
}
|
|
98
|
-
walkNode(child, plainText, ranges, mathAtoms, activeStyles, blankRuns)
|
|
157
|
+
walkNode(child, plainText, ranges, mathAtoms, activeStyles, blankRuns, blockSeparators)
|
|
99
158
|
}
|
|
100
159
|
|
|
101
160
|
if (styleType != null) {
|
package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt
CHANGED
|
@@ -16,6 +16,7 @@ import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
|
|
|
16
16
|
import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
|
|
17
17
|
import com.swmansion.enriched.markdown.input.events.OnLinkDetectedEvent
|
|
18
18
|
import com.swmansion.enriched.markdown.input.events.OnMathPressEvent
|
|
19
|
+
import com.swmansion.enriched.markdown.input.events.OnPasteEvent
|
|
19
20
|
import com.swmansion.enriched.markdown.input.events.OnRequestCaretRectResultEvent
|
|
20
21
|
import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
|
|
21
22
|
import com.swmansion.enriched.markdown.input.events.OnStartMentionEvent
|
|
@@ -99,6 +100,13 @@ class InputEventEmitter(
|
|
|
99
100
|
dispatch(OnMathPressEvent(surfaceId(), view.id, latex, start, end, displayMode, x, y, width, height))
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
fun emitPaste(
|
|
104
|
+
text: String,
|
|
105
|
+
markdown: String,
|
|
106
|
+
) {
|
|
107
|
+
dispatch(OnPasteEvent(surfaceId(), view.id, text, markdown))
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
fun emitStartMention(indicator: String) {
|
|
103
111
|
dispatch(OnStartMentionEvent(surfaceId(), view.id, indicator))
|
|
104
112
|
}
|
package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt
CHANGED
|
@@ -8,21 +8,49 @@ class InputLayoutManager(
|
|
|
8
8
|
) {
|
|
9
9
|
private var forceHeightRecalculationCounter = 0
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* An invalidation that arrived before `stateWrapper` existed. Fabric applies every prop
|
|
13
|
+
* (and runs `onAfterUpdateTransaction`) inside `ViewManager.updateProperties`, which runs
|
|
14
|
+
* *before* `updateState` assigns the wrapper — so the whole mount transaction invalidates
|
|
15
|
+
* into the void. Without this flag the measurement store stays empty and Yoga keeps the
|
|
16
|
+
* `initialMeasure()` approximation until the next text/prop change.
|
|
17
|
+
*/
|
|
18
|
+
private var pending = false
|
|
19
|
+
|
|
11
20
|
fun invalidateLayout() {
|
|
12
|
-
|
|
21
|
+
val stateWrapper = view.stateWrapper
|
|
22
|
+
if (stateWrapper == null) {
|
|
23
|
+
pending = true
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
pending = false
|
|
13
27
|
|
|
14
28
|
val text = view.text
|
|
15
29
|
val paint = view.paint
|
|
16
30
|
|
|
31
|
+
// First call for this view measures against cachedWidth = 0 and stores a throwaway size;
|
|
32
|
+
// Yoga immediately re-measures through getMeasureById() with the real width and overwrites
|
|
33
|
+
// it. The state update below is what makes that second pass happen.
|
|
17
34
|
val needUpdate = InputMeasurementStore.store(view.id, text, paint)
|
|
18
35
|
if (!needUpdate) return
|
|
19
36
|
|
|
20
37
|
val state = Arguments.createMap()
|
|
21
38
|
state.putInt("forceHeightRecalculationCounter", forceHeightRecalculationCounter++)
|
|
22
|
-
|
|
39
|
+
stateWrapper.updateState(state)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replays an invalidation that was dropped while `stateWrapper` was null. Called once the
|
|
44
|
+
* wrapper is assigned, i.e. after every prop of the mount transaction has been applied — so
|
|
45
|
+
* the text carries its math chips and the paint carries the resolved typeface/size.
|
|
46
|
+
*/
|
|
47
|
+
fun flushPendingInvalidation() {
|
|
48
|
+
if (!pending) return
|
|
49
|
+
invalidateLayout()
|
|
23
50
|
}
|
|
24
51
|
|
|
25
52
|
fun release() {
|
|
53
|
+
pending = false
|
|
26
54
|
InputMeasurementStore.release(view.id)
|
|
27
55
|
}
|
|
28
56
|
}
|
package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt
CHANGED
|
@@ -10,6 +10,8 @@ import com.facebook.react.uimanager.PixelUtil
|
|
|
10
10
|
import com.facebook.yoga.YogaMeasureMode
|
|
11
11
|
import com.facebook.yoga.YogaMeasureOutput
|
|
12
12
|
import com.swmansion.enriched.markdown.input.formatting.InputParser
|
|
13
|
+
import com.swmansion.enriched.markdown.utils.common.getBooleanOrDefault
|
|
14
|
+
import com.swmansion.enriched.markdown.utils.common.getFloatOrDefault
|
|
13
15
|
import java.util.concurrent.ConcurrentHashMap
|
|
14
16
|
|
|
15
17
|
object InputMeasurementStore {
|
|
@@ -99,8 +101,11 @@ object InputMeasurementStore {
|
|
|
99
101
|
// invisible characters and over-estimates the height into extra lines, until the next text
|
|
100
102
|
// change forces a re-measure. Parsing first matches what the editor actually renders.
|
|
101
103
|
val markdown = props?.getString("defaultValue") ?: props?.getString("placeholder") ?: "I"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
// Must read the same flag the view parses with, or the measured height and the
|
|
105
|
+
// rendered height disagree: `blockSyntax = false` keeps list markers and `#` as text.
|
|
106
|
+
val blockSyntax = props.getBooleanOrDefault("blockSyntax", true)
|
|
107
|
+
val text = InputParser.parseToPlainTextAndRanges(markdown, blockSyntax).plainText
|
|
108
|
+
val fontSize = props.getFloatOrDefault("fontSize", 16f)
|
|
104
109
|
val spSize = kotlin.math.ceil(PixelUtil.toPixelFromSP(fontSize).toDouble()).toFloat()
|
|
105
110
|
|
|
106
111
|
val defaultView = AppCompatEditText(context)
|
|
@@ -13,6 +13,7 @@ open class MathChipStyle(
|
|
|
13
13
|
val paddingVertical: Float,
|
|
14
14
|
val selectedBackgroundColor: Int?,
|
|
15
15
|
val selectedBorderColor: Int?,
|
|
16
|
+
val fontWeight: String = "normal",
|
|
16
17
|
) {
|
|
17
18
|
open fun isBlock(): Boolean = false
|
|
18
19
|
}
|
|
@@ -28,6 +29,7 @@ class MathBlockStyle(
|
|
|
28
29
|
paddingVertical: Float,
|
|
29
30
|
selectedBackgroundColor: Int?,
|
|
30
31
|
selectedBorderColor: Int?,
|
|
32
|
+
fontWeight: String = "normal",
|
|
31
33
|
val marginTop: Float,
|
|
32
34
|
val marginBottom: Float,
|
|
33
35
|
val alignment: Layout.Alignment,
|
|
@@ -42,6 +44,7 @@ class MathBlockStyle(
|
|
|
42
44
|
paddingVertical,
|
|
43
45
|
selectedBackgroundColor,
|
|
44
46
|
selectedBorderColor,
|
|
47
|
+
fontWeight,
|
|
45
48
|
) {
|
|
46
49
|
override fun isBlock(): Boolean = true
|
|
47
50
|
}
|
|
@@ -4,11 +4,14 @@ import android.annotation.SuppressLint
|
|
|
4
4
|
import android.graphics.Typeface
|
|
5
5
|
import android.text.TextPaint
|
|
6
6
|
import android.text.style.MetricAffectingSpan
|
|
7
|
+
import com.swmansion.enriched.markdown.renderer.SpanStyleCache
|
|
7
8
|
import com.swmansion.enriched.markdown.styles.StyleConfig
|
|
9
|
+
import com.swmansion.enriched.markdown.utils.text.extensions.applyColorPreserving
|
|
8
10
|
|
|
9
11
|
class HeadingSpan(
|
|
10
12
|
val level: Int,
|
|
11
13
|
styleConfig: StyleConfig,
|
|
14
|
+
private val styleCache: SpanStyleCache,
|
|
12
15
|
) : MetricAffectingSpan() {
|
|
13
16
|
private val fontSize: Float = styleConfig.headingStyles[level]!!.fontSize
|
|
14
17
|
private val color: Int = styleConfig.headingStyles[level]!!.color
|
|
@@ -16,7 +19,11 @@ class HeadingSpan(
|
|
|
16
19
|
|
|
17
20
|
override fun updateDrawState(tp: TextPaint) {
|
|
18
21
|
applyHeadingStyle(tp)
|
|
19
|
-
|
|
22
|
+
// Inline spans (link, code, strong/em with their own color) are attached to the range
|
|
23
|
+
// BEFORE this block span, and TextLine replays every CharacterStyle in insertion order,
|
|
24
|
+
// so an unconditional `tp.color = color` here would repaint them. Same guard used by
|
|
25
|
+
// BlockquoteSpan, BaseListSpan, CodeBlockSpan, StrongSpan and EmphasisSpan.
|
|
26
|
+
tp.applyColorPreserving(color, *styleCache.colorsToPreserve)
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
override fun updateMeasureState(tp: TextPaint) {
|