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,1736 @@
|
|
|
1
|
+
package com.jetmarkdown.editor
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.Typeface
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.text.Editable
|
|
11
|
+
import android.text.InputType
|
|
12
|
+
import android.text.Spanned
|
|
13
|
+
import android.text.TextPaint
|
|
14
|
+
import android.text.TextWatcher
|
|
15
|
+
import android.util.TypedValue
|
|
16
|
+
import android.view.Gravity
|
|
17
|
+
import android.view.View
|
|
18
|
+
import android.view.inputmethod.InputMethodManager
|
|
19
|
+
import android.widget.EditText
|
|
20
|
+
import com.facebook.react.bridge.Arguments
|
|
21
|
+
import com.facebook.react.bridge.ReactContext
|
|
22
|
+
import com.facebook.react.bridge.WritableMap
|
|
23
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
24
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
25
|
+
import com.facebook.react.uimanager.events.Event
|
|
26
|
+
import com.jetmarkdown.JetMarkdownNative
|
|
27
|
+
import com.jetmarkdown.style.StyleConfig
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* WYSIWYG markdown editor: an EditText that publishes its content height
|
|
31
|
+
* into the shadow-node state (autogrow) and emits editing events. Root text
|
|
32
|
+
* attributes come from the same base/paragraph cascade the viewer uses.
|
|
33
|
+
*/
|
|
34
|
+
@SuppressLint("AppCompatCustomView")
|
|
35
|
+
class JetMarkdownEditorView(context: Context) : EditText(context) {
|
|
36
|
+
var stateWrapper: StateWrapper? = null
|
|
37
|
+
|
|
38
|
+
private var stylesJson: String = ""
|
|
39
|
+
private var defaultValueApplied = false
|
|
40
|
+
|
|
41
|
+
var allowFontScaling = true
|
|
42
|
+
set(value) {
|
|
43
|
+
if (field != value) {
|
|
44
|
+
field = value
|
|
45
|
+
applyTextStyles()
|
|
46
|
+
refreshDisplaySpans(text)
|
|
47
|
+
invalidate()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private var multiline = true
|
|
52
|
+
private var autoCorrectEnabled = true
|
|
53
|
+
private var capitalizeMode = "sentences"
|
|
54
|
+
private var pendingAutoFocus = false
|
|
55
|
+
private var lastPublishedHeight = 0f
|
|
56
|
+
|
|
57
|
+
// Autogrow cap (px); 0 = unbounded. Past it the editor scrolls
|
|
58
|
+
// internally like a textarea.
|
|
59
|
+
private var maxHeightPx = 0
|
|
60
|
+
private var contentExceedsMax = false
|
|
61
|
+
private var scrollAllowed = true
|
|
62
|
+
|
|
63
|
+
// Marks armed for text typed at the collapsed cursor; re-derived from the
|
|
64
|
+
// character before the caret whenever the selection moves outside an edit.
|
|
65
|
+
private var pendingMarks = 0
|
|
66
|
+
|
|
67
|
+
// Caret position where marks were explicitly toggled. The IME re-syncs
|
|
68
|
+
// its selection asynchronously after external text mutations; a selection
|
|
69
|
+
// event at the SAME position must not wipe a just-armed mark.
|
|
70
|
+
private var pendingArmedAt = -1
|
|
71
|
+
private var lastStateKey = -1L
|
|
72
|
+
private var editInProgress = false
|
|
73
|
+
private var suppressWatcher = false
|
|
74
|
+
private var changeStart = 0
|
|
75
|
+
private var changeInserted = 0
|
|
76
|
+
private var changeReplaced = 0
|
|
77
|
+
|
|
78
|
+
// Per-character mark flags of the region an IME re-commit is about to
|
|
79
|
+
// replace ("a" → "ab" replaces the composing word): SSB collapses spans
|
|
80
|
+
// whose whole run is swapped out, so the prefix's marks are restored from
|
|
81
|
+
// this snapshot afterwards.
|
|
82
|
+
private var replacedMarkFlags: IntArray? = null
|
|
83
|
+
|
|
84
|
+
private fun markFlagsAt(spanned: Spanned, offset: Int): Int {
|
|
85
|
+
var flags = 0
|
|
86
|
+
for (span in spanned.getSpans(offset, offset + 1, EditorMarkSpan::class.java)) {
|
|
87
|
+
if (spanned.getSpanStart(span) <= offset && spanned.getSpanEnd(span) > offset) {
|
|
88
|
+
flags = flags or span.mark
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return flags
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// TextView's constructor fires onSelectionChanged before any of this
|
|
95
|
+
// class's fields exist; nothing below may run until init completes.
|
|
96
|
+
private var constructed = false
|
|
97
|
+
|
|
98
|
+
// Source of truth for per-line blocks (packed type shl 8 or level, one
|
|
99
|
+
// entry per text line). Spliced in the TextWatcher as lines come and go.
|
|
100
|
+
private val lineBlocks = ArrayList<Int>()
|
|
101
|
+
private var editLine = 0
|
|
102
|
+
private var removedNewlines = 0
|
|
103
|
+
private var insertedNewlines = 0
|
|
104
|
+
|
|
105
|
+
// Mention trigger session.
|
|
106
|
+
var mentionTriggers: List<String> = emptyList()
|
|
107
|
+
private var mentionActive = false
|
|
108
|
+
private var mentionTrigger = ""
|
|
109
|
+
private var mentionStart = 0
|
|
110
|
+
private var lastMentionQuery: String? = null
|
|
111
|
+
private var linkColor = DEFAULT_LINK_COLOR
|
|
112
|
+
|
|
113
|
+
// Resolved lineHeight per context (px); 0 = natural. Headings and code
|
|
114
|
+
// use their own element style, everything else the base/paragraph
|
|
115
|
+
// cascade.
|
|
116
|
+
private var lineHeightPx = 0
|
|
117
|
+
private val headingLineHeightsPx = IntArray(7)
|
|
118
|
+
private var codeLineHeightPx = 0
|
|
119
|
+
|
|
120
|
+
// Drawn manually: Fabric never drives onMeasure, and TextView's native
|
|
121
|
+
// hint rendering depends on measure-time layout construction.
|
|
122
|
+
private var placeholderText: String? = null
|
|
123
|
+
private var placeholderColor: Int = DEFAULT_PLACEHOLDER_COLOR
|
|
124
|
+
private val placeholderPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
125
|
+
|
|
126
|
+
private val density = context.resources.displayMetrics.density
|
|
127
|
+
private val defaultHighlightColor = highlightColor
|
|
128
|
+
|
|
129
|
+
// Fabric assigns exact frames and never runs a parent measure/layout pass,
|
|
130
|
+
// so TextView's internal Layout is not rebuilt after programmatic setText
|
|
131
|
+
// or hint changes. Re-run measure/layout in place against the current
|
|
132
|
+
// frame (the standard fix for text-bearing custom views under Fabric).
|
|
133
|
+
private var relayoutPending = false
|
|
134
|
+
|
|
135
|
+
private val measureAndLayout = Runnable {
|
|
136
|
+
relayoutPending = false
|
|
137
|
+
if (width > 0 && height > 0) {
|
|
138
|
+
measure(
|
|
139
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
140
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
|
141
|
+
)
|
|
142
|
+
layout(left, top, right, bottom)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
override fun requestLayout() {
|
|
147
|
+
super.requestLayout()
|
|
148
|
+
if (!relayoutPending) {
|
|
149
|
+
relayoutPending = true
|
|
150
|
+
post(measureAndLayout)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
init {
|
|
155
|
+
background = null
|
|
156
|
+
gravity = Gravity.TOP or Gravity.START
|
|
157
|
+
applyTextStyles()
|
|
158
|
+
applyInputType()
|
|
159
|
+
|
|
160
|
+
addTextChangedListener(object : TextWatcher {
|
|
161
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
|
162
|
+
editInProgress = true
|
|
163
|
+
replacedMarkFlags = null
|
|
164
|
+
if (suppressWatcher || s == null) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
editLine = countNewlines(s, 0, start)
|
|
168
|
+
removedNewlines = countNewlines(s, start, start + count)
|
|
169
|
+
// Word-sized replacements that grow are IME re-commits; snapshot the
|
|
170
|
+
// replaced characters' marks so the prefix can be restored. The size
|
|
171
|
+
// cap keeps selection replacements (e.g. a clipboard-chip paste over
|
|
172
|
+
// a long selection) out of this path — composing words are short.
|
|
173
|
+
if (count in 1..48 && after >= count && s is Spanned) {
|
|
174
|
+
replacedMarkFlags = IntArray(count) { markFlagsAt(s, start + it) }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
|
179
|
+
if (suppressWatcher || s == null) {
|
|
180
|
+
// A nested programmatic edit (block exit) must not clobber the
|
|
181
|
+
// outer edit's bookkeeping mid-afterTextChanged.
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
changeStart = start
|
|
185
|
+
changeInserted = count
|
|
186
|
+
changeReplaced = before
|
|
187
|
+
insertedNewlines = countNewlines(s, start, start + count)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override fun afterTextChanged(s: Editable?) {
|
|
191
|
+
editInProgress = false
|
|
192
|
+
if (suppressWatcher || s == null) {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
spliceLineBlocks()
|
|
196
|
+
if (s.isEmpty()) {
|
|
197
|
+
// Deleting all content resets the document to one plain line;
|
|
198
|
+
// a block must not haunt an empty editor.
|
|
199
|
+
lineBlocks.clear()
|
|
200
|
+
ensureLineBlocks()
|
|
201
|
+
pendingMarks = 0
|
|
202
|
+
}
|
|
203
|
+
var exitedList = false
|
|
204
|
+
if (insertedNewlines == 1 && changeInserted == 1 && removedNewlines == 0) {
|
|
205
|
+
val block = lineBlocks.getOrElse(editLine) { 0 }
|
|
206
|
+
// The Enter split the line at the caret: editLine holds the text
|
|
207
|
+
// before the caret, editLine + 1 the text after it.
|
|
208
|
+
val beforeEmpty = lineContentRange(editLine).isEmpty()
|
|
209
|
+
val afterEmpty = lineContentRange(editLine + 1).isEmpty()
|
|
210
|
+
if (block != 0 && beforeEmpty && afterEmpty) {
|
|
211
|
+
// Enter on any empty formatted line (list item, quote, code
|
|
212
|
+
// block, heading) exits the block instead of continuing it.
|
|
213
|
+
lineBlocks[editLine] = 0
|
|
214
|
+
lineBlocks.removeAt(editLine + 1)
|
|
215
|
+
suppressWatcher = true
|
|
216
|
+
s.delete(changeStart, changeStart + 1)
|
|
217
|
+
suppressWatcher = false
|
|
218
|
+
exitedList = true
|
|
219
|
+
} else if (EditorBlocks.type(block) == EditorBlocks.HEADING) {
|
|
220
|
+
if (beforeEmpty) {
|
|
221
|
+
// Enter at the start of a heading: the heading stays with its
|
|
222
|
+
// text and a plain empty line opens above it.
|
|
223
|
+
lineBlocks[editLine] = 0
|
|
224
|
+
} else if (afterEmpty) {
|
|
225
|
+
// Enter at the end of a heading does not continue it.
|
|
226
|
+
lineBlocks[editLine + 1] = 0
|
|
227
|
+
}
|
|
228
|
+
// Mid-heading splits keep the heading on both halves.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// The IME often re-commits a whole composing word to append one
|
|
232
|
+
// character ("a" → "ab"). Stamping pending marks over the full
|
|
233
|
+
// insertion would restyle the re-committed prefix (whose spans SSB
|
|
234
|
+
// already preserved), so only the genuinely new suffix is stamped.
|
|
235
|
+
if (!exitedList && changeInserted > changeReplaced && pendingMarks != 0) {
|
|
236
|
+
val stampStart = changeStart + changeReplaced
|
|
237
|
+
for (mark in EditorMarks.ALL) {
|
|
238
|
+
if (pendingMarks and mark != 0) {
|
|
239
|
+
applyMark(s, mark, stampStart, changeStart + changeInserted)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Restore the re-committed prefix's marks from the snapshot (the
|
|
244
|
+
// replacement collapsed any span that covered exactly that run).
|
|
245
|
+
val restored = replacedMarkFlags
|
|
246
|
+
if (restored != null && changeReplaced == restored.size &&
|
|
247
|
+
changeInserted >= changeReplaced
|
|
248
|
+
) {
|
|
249
|
+
for (i in 0 until changeReplaced) {
|
|
250
|
+
val flags = restored[i]
|
|
251
|
+
if (flags == 0) {
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
for (mark in EditorMarks.ALL) {
|
|
255
|
+
if (flags and mark != 0) {
|
|
256
|
+
applyMark(s, mark, changeStart + i, changeStart + i + 1)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
replacedMarkFlags = null
|
|
262
|
+
// Typing strictly inside an atomic token demotes it to plain text.
|
|
263
|
+
if (changeInserted > 0) {
|
|
264
|
+
for (span in s.getSpans(changeStart, changeStart, LinkDataSpan::class.java)) {
|
|
265
|
+
if (span.atomic && s.getSpanStart(span) < changeStart &&
|
|
266
|
+
s.getSpanEnd(span) > changeStart + changeInserted
|
|
267
|
+
) {
|
|
268
|
+
s.removeSpan(span)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (insertedNewlines == 0 && removedNewlines == 0 && s.isNotEmpty()) {
|
|
273
|
+
// Plain in-line keystroke: derived spans on other lines are still
|
|
274
|
+
// valid; rebuilding them all would reflow the whole document.
|
|
275
|
+
refreshDisplaySpansAround(s, changeStart, changeStart + changeInserted)
|
|
276
|
+
} else {
|
|
277
|
+
refreshDisplaySpans(s)
|
|
278
|
+
}
|
|
279
|
+
// The armed-mark guard is positional; any text edit invalidates it.
|
|
280
|
+
pendingArmedAt = -1
|
|
281
|
+
emitContentChanged()
|
|
282
|
+
if (changeInserted == 1 && changeStart < s.length && isWordBreak(s[changeStart])) {
|
|
283
|
+
detectLinkBefore(changeStart)
|
|
284
|
+
}
|
|
285
|
+
updateMentionSession()
|
|
286
|
+
emitState()
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
setOnFocusChangeListener { _, hasFocus ->
|
|
291
|
+
emitEvent(if (hasFocus) "topEditorFocus" else "topEditorBlur") { }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
constructed = true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Root text attributes: base (style prop text keys) then paragraph,
|
|
298
|
+
// floored at 16pt black — the same cascade root the viewer uses.
|
|
299
|
+
private fun applyTextStyles() {
|
|
300
|
+
val styles = StyleConfig.from(stylesJson)
|
|
301
|
+
|
|
302
|
+
var fontSize = 16f
|
|
303
|
+
var fontFamily: String? = null
|
|
304
|
+
var color = Color.BLACK
|
|
305
|
+
var lineHeight = 0f
|
|
306
|
+
for (key in listOf("base", "paragraph")) {
|
|
307
|
+
val spec = styles.textStyleFor(key) ?: continue
|
|
308
|
+
spec.fontSize?.let { fontSize = it }
|
|
309
|
+
spec.fontFamily?.let { fontFamily = it }
|
|
310
|
+
spec.color?.let { color = it }
|
|
311
|
+
spec.lineHeight?.let { lineHeight = it }
|
|
312
|
+
}
|
|
313
|
+
// Must match the shadow node's fontSizeMultiplier (the system scale).
|
|
314
|
+
val fontScale =
|
|
315
|
+
if (allowFontScaling) resources.configuration.fontScale else 1.0f
|
|
316
|
+
fontSize *= fontScale
|
|
317
|
+
lineHeight *= fontScale
|
|
318
|
+
lineHeightPx = (lineHeight * density).toInt()
|
|
319
|
+
for (level in 1..6) {
|
|
320
|
+
headingLineHeightsPx[level] =
|
|
321
|
+
((styles.textStyleFor("h$level")?.lineHeight ?: 0f) * density * fontScale).toInt()
|
|
322
|
+
}
|
|
323
|
+
codeLineHeightPx =
|
|
324
|
+
styles.textStyleFor("codeBlock")?.lineHeight
|
|
325
|
+
?.let { (it * density * fontScale).toInt() }
|
|
326
|
+
?: lineHeightPx
|
|
327
|
+
|
|
328
|
+
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * density)
|
|
329
|
+
setTextColor(color)
|
|
330
|
+
// ReactFontManager understands RN/Expo custom fonts; Typeface.create
|
|
331
|
+
// silently falls back to the system default for them.
|
|
332
|
+
typeface = com.jetmarkdown.style.Fonts.resolve(context, fontFamily, 400, italic = false)
|
|
333
|
+
styles.textStyleFor("link")?.color?.let { linkColor = it }
|
|
334
|
+
|
|
335
|
+
setPadding(
|
|
336
|
+
(styles.paddingLeft * density).toInt(),
|
|
337
|
+
(styles.paddingTop * density).toInt(),
|
|
338
|
+
(styles.paddingRight * density).toInt(),
|
|
339
|
+
(styles.paddingBottom * density).toInt(),
|
|
340
|
+
)
|
|
341
|
+
styles.backgroundColor?.let { setBackgroundColor(it) }
|
|
342
|
+
|
|
343
|
+
publishHeight()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Autocorrect/autocapitalize/suggestions are suppressed while the caret
|
|
347
|
+
// is in a code context (`let` must not become `Let`).
|
|
348
|
+
private var codeContextActive = false
|
|
349
|
+
|
|
350
|
+
private fun lineIsCode(line: Int): Boolean =
|
|
351
|
+
EditorBlocks.type(lineBlocks.getOrElse(line) { 0 }) == EditorBlocks.CODE
|
|
352
|
+
|
|
353
|
+
private fun caretInCodeContext(): Boolean {
|
|
354
|
+
if (pendingMarks and EditorMarks.INLINE_CODE != 0) {
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
return lineIsCode(lineIndexAt(selectionStart.coerceAtLeast(0)))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private fun updateInputTypeForContext() {
|
|
361
|
+
val inCode = caretInCodeContext()
|
|
362
|
+
if (inCode != codeContextActive) {
|
|
363
|
+
codeContextActive = inCode
|
|
364
|
+
applyInputType()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Setting inputType resets the typeface, so callers reapply styles after.
|
|
369
|
+
private fun applyInputType() {
|
|
370
|
+
var type = InputType.TYPE_CLASS_TEXT
|
|
371
|
+
if (multiline) {
|
|
372
|
+
type = type or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
|
373
|
+
}
|
|
374
|
+
if (autoCorrectEnabled && !codeContextActive) {
|
|
375
|
+
type = type or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
|
|
376
|
+
}
|
|
377
|
+
if (codeContextActive) {
|
|
378
|
+
type = type or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
|
379
|
+
}
|
|
380
|
+
type = type or when (if (codeContextActive) "none" else capitalizeMode) {
|
|
381
|
+
"words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
|
|
382
|
+
"characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
|
|
383
|
+
"none" -> 0
|
|
384
|
+
else -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
|
385
|
+
}
|
|
386
|
+
val selection = selectionStart
|
|
387
|
+
val selectionUpper = selectionEnd
|
|
388
|
+
inputType = type
|
|
389
|
+
isSingleLine = !multiline
|
|
390
|
+
if (selection in 0..text.length && selectionUpper in selection..text.length) {
|
|
391
|
+
setSelection(selection, selectionUpper)
|
|
392
|
+
}
|
|
393
|
+
applyTextStyles()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fun setStylesJson(value: String?) {
|
|
397
|
+
val json = value ?: ""
|
|
398
|
+
if (json != stylesJson) {
|
|
399
|
+
stylesJson = json
|
|
400
|
+
applyTextStyles()
|
|
401
|
+
// Existing derived spans carry the OLD line heights / link color; a
|
|
402
|
+
// theme change must restyle content that is already there.
|
|
403
|
+
refreshDisplaySpans(text)
|
|
404
|
+
invalidate()
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fun setDefaultValue(value: String?) {
|
|
409
|
+
if (!defaultValueApplied) {
|
|
410
|
+
defaultValueApplied = true
|
|
411
|
+
if (!value.isNullOrEmpty()) {
|
|
412
|
+
setMarkdownValue(value)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Parsed as markdown into text + inline-mark spans + line blocks. */
|
|
418
|
+
fun setMarkdownValue(markdown: String) {
|
|
419
|
+
val decoded = JetMarkdownNative.editorFromMarkdown(markdown)
|
|
420
|
+
suppressWatcher = true
|
|
421
|
+
setText(decoded.text)
|
|
422
|
+
suppressWatcher = false
|
|
423
|
+
val editable = text
|
|
424
|
+
applyRuns(editable, decoded.runs)
|
|
425
|
+
for ((index, url) in decoded.linkUrls.withIndex()) {
|
|
426
|
+
val start = decoded.linkRanges[index * 2].coerceIn(0, editable.length)
|
|
427
|
+
val end = decoded.linkRanges[index * 2 + 1].coerceIn(start, editable.length)
|
|
428
|
+
if (end > start) {
|
|
429
|
+
editable.setSpan(
|
|
430
|
+
LinkDataSpan(url, atomic = false),
|
|
431
|
+
start,
|
|
432
|
+
end,
|
|
433
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
lineBlocks.clear()
|
|
438
|
+
var index = 0
|
|
439
|
+
while (index + 2 <= decoded.lineBlocks.size) {
|
|
440
|
+
lineBlocks.add(
|
|
441
|
+
EditorBlocks.pack(decoded.lineBlocks[index], decoded.lineBlocks[index + 1]),
|
|
442
|
+
)
|
|
443
|
+
index += 2
|
|
444
|
+
}
|
|
445
|
+
ensureLineBlocks()
|
|
446
|
+
refreshDisplaySpans(editable)
|
|
447
|
+
setSelection(text.length)
|
|
448
|
+
emitContentChanged()
|
|
449
|
+
emitState()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private fun applyRuns(editable: Editable, runs: IntArray) {
|
|
453
|
+
var index = 0
|
|
454
|
+
while (index + 3 <= runs.size) {
|
|
455
|
+
val start = runs[index].coerceIn(0, editable.length)
|
|
456
|
+
val end = runs[index + 1].coerceIn(start, editable.length)
|
|
457
|
+
val flags = runs[index + 2]
|
|
458
|
+
if (end > start) {
|
|
459
|
+
for (mark in EditorMarks.ALL) {
|
|
460
|
+
if (flags and mark != 0) {
|
|
461
|
+
editable.setSpan(
|
|
462
|
+
EditorMarkSpan(mark),
|
|
463
|
+
start,
|
|
464
|
+
end,
|
|
465
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
index += 3
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private fun countNewlines(s: CharSequence, from: Int, to: Int): Int {
|
|
475
|
+
var count = 0
|
|
476
|
+
for (i in from.coerceAtLeast(0) until to.coerceAtMost(s.length)) {
|
|
477
|
+
if (s[i] == '\n') {
|
|
478
|
+
count++
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return count
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private fun lineCount(): Int = countNewlines(text, 0, text.length) + 1
|
|
485
|
+
|
|
486
|
+
private fun lineIndexAt(offset: Int): Int = countNewlines(text, 0, offset)
|
|
487
|
+
|
|
488
|
+
private fun lineStartOffset(index: Int): Int {
|
|
489
|
+
var start = 0
|
|
490
|
+
var line = 0
|
|
491
|
+
while (line < index) {
|
|
492
|
+
val newline = text.indexOf('\n', start)
|
|
493
|
+
if (newline == -1) {
|
|
494
|
+
return text.length
|
|
495
|
+
}
|
|
496
|
+
start = newline + 1
|
|
497
|
+
line++
|
|
498
|
+
}
|
|
499
|
+
return start
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private fun lineContentRange(index: Int): IntRange {
|
|
503
|
+
val start = lineStartOffset(index)
|
|
504
|
+
val newline = text.indexOf('\n', start)
|
|
505
|
+
val end = if (newline == -1) text.length else newline
|
|
506
|
+
return start until end
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private fun ensureLineBlocks() {
|
|
510
|
+
val count = lineCount()
|
|
511
|
+
while (lineBlocks.size < count) {
|
|
512
|
+
lineBlocks.add(0)
|
|
513
|
+
}
|
|
514
|
+
while (lineBlocks.size > count) {
|
|
515
|
+
lineBlocks.removeAt(lineBlocks.size - 1)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Keeps lineBlocks aligned as the edit adds/removes lines: new lines
|
|
520
|
+
// inherit the edited line's block so lists and quotes continue on Enter.
|
|
521
|
+
private fun spliceLineBlocks() {
|
|
522
|
+
if (lineBlocks.isEmpty()) {
|
|
523
|
+
ensureLineBlocks()
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
val anchor = editLine.coerceIn(0, lineBlocks.size - 1)
|
|
527
|
+
val inherited = lineBlocks[anchor]
|
|
528
|
+
repeat(removedNewlines) {
|
|
529
|
+
if (anchor + 1 < lineBlocks.size) {
|
|
530
|
+
lineBlocks.removeAt(anchor + 1)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
repeat(insertedNewlines) {
|
|
534
|
+
lineBlocks.add((anchor + 1).coerceAtMost(lineBlocks.size), inherited)
|
|
535
|
+
}
|
|
536
|
+
ensureLineBlocks()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Toggles a block over every line the selection touches. */
|
|
540
|
+
fun toggleBlock(type: Int, level: Int) {
|
|
541
|
+
ensureLineBlocks()
|
|
542
|
+
val target = EditorBlocks.pack(type, level)
|
|
543
|
+
val startLine = lineIndexAt(selectionStart.coerceAtLeast(0))
|
|
544
|
+
val endLine = lineIndexAt(selectionEnd.coerceAtLeast(selectionStart.coerceAtLeast(0)))
|
|
545
|
+
val all = (startLine..endLine).all { lineBlocks.getOrElse(it) { 0 } == target }
|
|
546
|
+
val next = if (all) 0 else target
|
|
547
|
+
for (index in startLine..endLine) {
|
|
548
|
+
if (index < lineBlocks.size) {
|
|
549
|
+
lineBlocks[index] = next
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (EditorBlocks.type(next) == EditorBlocks.CODE) {
|
|
553
|
+
// A code fence carries raw text only: inline marks and links on the
|
|
554
|
+
// converted lines would be dropped by the serializer, so shed them
|
|
555
|
+
// now rather than displaying formatting that cannot survive.
|
|
556
|
+
val editable = text
|
|
557
|
+
val rangeStart = lineStartOffset(startLine)
|
|
558
|
+
val rangeEnd = lineContentRange(endLine).last + 1
|
|
559
|
+
for (mark in EditorMarks.ALL) {
|
|
560
|
+
removeMark(editable, mark, rangeStart, rangeEnd)
|
|
561
|
+
}
|
|
562
|
+
for (span in editable.getSpans(rangeStart, rangeEnd, LinkDataSpan::class.java)) {
|
|
563
|
+
editable.removeSpan(span)
|
|
564
|
+
}
|
|
565
|
+
pendingMarks = 0
|
|
566
|
+
}
|
|
567
|
+
refreshDisplaySpans(text)
|
|
568
|
+
updateInputTypeForContext()
|
|
569
|
+
invalidate()
|
|
570
|
+
emitContentChanged()
|
|
571
|
+
emitState()
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Backspace at the start of a formatted line clears the block first
|
|
575
|
+
// (intercepted at the InputConnection level; TextWatcher cannot veto).
|
|
576
|
+
override fun onCreateInputConnection(outAttrs: android.view.inputmethod.EditorInfo):
|
|
577
|
+
android.view.inputmethod.InputConnection? {
|
|
578
|
+
val base = super.onCreateInputConnection(outAttrs) ?: return null
|
|
579
|
+
return object : android.view.inputmethod.InputConnectionWrapper(base, true) {
|
|
580
|
+
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
|
581
|
+
if (beforeLength == 1 && afterLength == 0 && interceptBackspace()) {
|
|
582
|
+
return true
|
|
583
|
+
}
|
|
584
|
+
return super.deleteSurroundingText(beforeLength, afterLength)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
override fun sendKeyEvent(event: android.view.KeyEvent): Boolean {
|
|
588
|
+
if (event.action == android.view.KeyEvent.ACTION_DOWN &&
|
|
589
|
+
event.keyCode == android.view.KeyEvent.KEYCODE_DEL &&
|
|
590
|
+
interceptBackspace()
|
|
591
|
+
) {
|
|
592
|
+
return true
|
|
593
|
+
}
|
|
594
|
+
return super.sendKeyEvent(event)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private fun interceptBackspace(): Boolean {
|
|
600
|
+
val start = selectionStart
|
|
601
|
+
if (start != selectionEnd || start < 0) {
|
|
602
|
+
return false
|
|
603
|
+
}
|
|
604
|
+
val editable = text
|
|
605
|
+
if (start > 0) {
|
|
606
|
+
// Backspacing into an atomic token removes the whole token.
|
|
607
|
+
for (span in editable.getSpans(start - 1, start, LinkDataSpan::class.java)) {
|
|
608
|
+
if (!span.atomic) {
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
val spanStart = editable.getSpanStart(span)
|
|
612
|
+
val spanEnd = editable.getSpanEnd(span)
|
|
613
|
+
if (start - 1 in spanStart until spanEnd) {
|
|
614
|
+
editable.removeSpan(span)
|
|
615
|
+
editable.delete(spanStart, spanEnd)
|
|
616
|
+
return true
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// Backspace at a line start (including the document start) clears the
|
|
621
|
+
// line's block before deleting anything.
|
|
622
|
+
if (start > 0 && text[start - 1] != '\n') {
|
|
623
|
+
return false
|
|
624
|
+
}
|
|
625
|
+
val line = lineIndexAt(start)
|
|
626
|
+
if (lineBlocks.getOrElse(line) { 0 } == 0) {
|
|
627
|
+
return false
|
|
628
|
+
}
|
|
629
|
+
lineBlocks[line] = 0
|
|
630
|
+
refreshDisplaySpans(text)
|
|
631
|
+
updateInputTypeForContext()
|
|
632
|
+
invalidate()
|
|
633
|
+
emitContentChanged()
|
|
634
|
+
emitState()
|
|
635
|
+
return true
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Paste never inserts directly: the clipboard is reported to JS, which
|
|
639
|
+
// owns the default insertion (via insertMarkdown) unless prevented.
|
|
640
|
+
override fun onTextContextMenuItem(id: Int): Boolean {
|
|
641
|
+
if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
|
|
642
|
+
reportPaste()
|
|
643
|
+
return true
|
|
644
|
+
}
|
|
645
|
+
return super.onTextContextMenuItem(id)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private fun reportPaste() {
|
|
649
|
+
val clipboard =
|
|
650
|
+
context.getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
|
|
651
|
+
val clip = clipboard?.primaryClip ?: return
|
|
652
|
+
var textContent = ""
|
|
653
|
+
val images = Arguments.createArray()
|
|
654
|
+
for (index in 0 until clip.itemCount) {
|
|
655
|
+
val item = clip.getItemAt(index)
|
|
656
|
+
val uri = item.uri
|
|
657
|
+
if (uri != null) {
|
|
658
|
+
val options = android.graphics.BitmapFactory.Options().apply {
|
|
659
|
+
inJustDecodeBounds = true
|
|
660
|
+
}
|
|
661
|
+
runCatching {
|
|
662
|
+
context.contentResolver.openInputStream(uri)?.use { stream ->
|
|
663
|
+
android.graphics.BitmapFactory.decodeStream(stream, null, options)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (options.outWidth > 0) {
|
|
667
|
+
images.pushMap(
|
|
668
|
+
Arguments.createMap().apply {
|
|
669
|
+
putString("url", uri.toString())
|
|
670
|
+
putDouble("width", options.outWidth.toDouble())
|
|
671
|
+
putDouble("height", options.outHeight.toDouble())
|
|
672
|
+
},
|
|
673
|
+
)
|
|
674
|
+
continue
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (textContent.isEmpty()) {
|
|
678
|
+
textContent = item.coerceToText(context)?.toString() ?: ""
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
emitEvent("topEditorPaste") {
|
|
682
|
+
putString("text", textContent)
|
|
683
|
+
putArray("images", images)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Parses markdown and inserts it at the cursor / over the selection. */
|
|
688
|
+
fun insertMarkdownAt(markdown: String) {
|
|
689
|
+
if (markdown.isEmpty()) {
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
val decoded = JetMarkdownNative.editorFromMarkdown(markdown)
|
|
693
|
+
val start = selectionStart.coerceAtLeast(0)
|
|
694
|
+
val end = selectionEnd.coerceAtLeast(start)
|
|
695
|
+
val startLine = lineIndexAt(start)
|
|
696
|
+
val editable = text
|
|
697
|
+
val removedLines = countNewlines(editable, start, end)
|
|
698
|
+
val decodedLineCount = decoded.lineBlocks.size / 2
|
|
699
|
+
suppressWatcher = true
|
|
700
|
+
editable.replace(start, end, decoded.text)
|
|
701
|
+
suppressWatcher = false
|
|
702
|
+
// The replace ran under suppressWatcher, so the watcher's lineBlocks
|
|
703
|
+
// splice never happened. Splice here or every line AFTER the paste
|
|
704
|
+
// point keeps a stale block entry (a heading below the caret would be
|
|
705
|
+
// silently stripped by the block-copy loop overwriting shifted
|
|
706
|
+
// indices).
|
|
707
|
+
repeat(removedLines) {
|
|
708
|
+
if (startLine + 1 < lineBlocks.size) {
|
|
709
|
+
lineBlocks.removeAt(startLine + 1)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
repeat((decodedLineCount - 1).coerceAtLeast(0)) {
|
|
713
|
+
lineBlocks.add((startLine + 1).coerceAtMost(lineBlocks.size), 0)
|
|
714
|
+
}
|
|
715
|
+
ensureLineBlocks()
|
|
716
|
+
|
|
717
|
+
var runIndex = 0
|
|
718
|
+
while (runIndex + 3 <= decoded.runs.size) {
|
|
719
|
+
val runStart = (decoded.runs[runIndex] + start).coerceIn(0, editable.length)
|
|
720
|
+
val runEnd = (decoded.runs[runIndex + 1] + start).coerceIn(runStart, editable.length)
|
|
721
|
+
val flags = decoded.runs[runIndex + 2]
|
|
722
|
+
if (runEnd > runStart) {
|
|
723
|
+
for (mark in EditorMarks.ALL) {
|
|
724
|
+
if (flags and mark != 0) {
|
|
725
|
+
editable.setSpan(
|
|
726
|
+
EditorMarkSpan(mark),
|
|
727
|
+
runStart,
|
|
728
|
+
runEnd,
|
|
729
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
runIndex += 3
|
|
735
|
+
}
|
|
736
|
+
for ((index, url) in decoded.linkUrls.withIndex()) {
|
|
737
|
+
val linkStart = (decoded.linkRanges[index * 2] + start).coerceIn(0, editable.length)
|
|
738
|
+
val linkEnd =
|
|
739
|
+
(decoded.linkRanges[index * 2 + 1] + start).coerceIn(linkStart, editable.length)
|
|
740
|
+
if (linkEnd > linkStart) {
|
|
741
|
+
editable.setSpan(
|
|
742
|
+
LinkDataSpan(url, atomic = false),
|
|
743
|
+
linkStart,
|
|
744
|
+
linkEnd,
|
|
745
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
for (k in 0 until decodedLineCount) {
|
|
750
|
+
val lineIndex = startLine + k
|
|
751
|
+
if (lineIndex >= lineBlocks.size) {
|
|
752
|
+
break
|
|
753
|
+
}
|
|
754
|
+
val block = EditorBlocks.pack(decoded.lineBlocks[k * 2], decoded.lineBlocks[k * 2 + 1])
|
|
755
|
+
// The first pasted line joins the current line; its existing block
|
|
756
|
+
// wins unless it is a plain paragraph.
|
|
757
|
+
if (k > 0 || lineBlocks[lineIndex] == 0) {
|
|
758
|
+
lineBlocks[lineIndex] = block
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
refreshDisplaySpans(editable)
|
|
763
|
+
setSelection((start + decoded.text.length).coerceAtMost(editable.length))
|
|
764
|
+
emitContentChanged()
|
|
765
|
+
emitState()
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Hardware keyboard formatting shortcuts (Ctrl+B / Ctrl+I).
|
|
769
|
+
override fun onKeyShortcut(keyCode: Int, event: android.view.KeyEvent): Boolean {
|
|
770
|
+
when (keyCode) {
|
|
771
|
+
android.view.KeyEvent.KEYCODE_B -> {
|
|
772
|
+
toggleMark(EditorMarks.BOLD)
|
|
773
|
+
return true
|
|
774
|
+
}
|
|
775
|
+
android.view.KeyEvent.KEYCODE_I -> {
|
|
776
|
+
toggleMark(EditorMarks.ITALIC)
|
|
777
|
+
return true
|
|
778
|
+
}
|
|
779
|
+
else -> return super.onKeyShortcut(keyCode, event)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Links the selection, or inserts a linked label at the caret. */
|
|
784
|
+
fun insertLink(url: String, label: String) {
|
|
785
|
+
if (url.isEmpty()) {
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
val start = selectionStart.coerceAtLeast(0)
|
|
789
|
+
val end = selectionEnd.coerceAtLeast(start)
|
|
790
|
+
val editable = text
|
|
791
|
+
if (start == end) {
|
|
792
|
+
val content = label.ifEmpty { url }
|
|
793
|
+
suppressWatcher = true
|
|
794
|
+
editable.insert(start, content)
|
|
795
|
+
suppressWatcher = false
|
|
796
|
+
ensureLineBlocks()
|
|
797
|
+
editable.setSpan(
|
|
798
|
+
LinkDataSpan(url, atomic = false),
|
|
799
|
+
start,
|
|
800
|
+
start + content.length,
|
|
801
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
802
|
+
)
|
|
803
|
+
setSelection(start + content.length)
|
|
804
|
+
} else {
|
|
805
|
+
editable.setSpan(
|
|
806
|
+
LinkDataSpan(url, atomic = false),
|
|
807
|
+
start,
|
|
808
|
+
end,
|
|
809
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
810
|
+
)
|
|
811
|
+
setSelection(end)
|
|
812
|
+
}
|
|
813
|
+
refreshDisplaySpans(editable)
|
|
814
|
+
emitContentChanged()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Removes the link covering the selection or the caret. */
|
|
818
|
+
fun removeLink() {
|
|
819
|
+
val start = selectionStart.coerceAtLeast(0)
|
|
820
|
+
val end = selectionEnd.coerceAtLeast(start)
|
|
821
|
+
val editable = text
|
|
822
|
+
val probeStart = if (start == end) (start - 1).coerceAtLeast(0) else start
|
|
823
|
+
var removed = false
|
|
824
|
+
for (span in editable.getSpans(probeStart, end, LinkDataSpan::class.java)) {
|
|
825
|
+
editable.removeSpan(span)
|
|
826
|
+
removed = true
|
|
827
|
+
}
|
|
828
|
+
if (removed) {
|
|
829
|
+
refreshDisplaySpans(editable)
|
|
830
|
+
emitContentChanged()
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Inserts an atomic mention token, replacing any active query. */
|
|
835
|
+
fun insertMention(trigger: String, label: String, url: String) {
|
|
836
|
+
if (label.isEmpty() || url.isEmpty()) {
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
val editable = text
|
|
840
|
+
val caret = selectionStart.coerceAtLeast(0)
|
|
841
|
+
val start = if (mentionActive) mentionStart else caret
|
|
842
|
+
val end = caret.coerceAtLeast(start)
|
|
843
|
+
val token = trigger + label
|
|
844
|
+
suppressWatcher = true
|
|
845
|
+
editable.replace(start, end, "$token ")
|
|
846
|
+
suppressWatcher = false
|
|
847
|
+
ensureLineBlocks()
|
|
848
|
+
editable.setSpan(
|
|
849
|
+
LinkDataSpan(url, atomic = true),
|
|
850
|
+
start,
|
|
851
|
+
start + token.length,
|
|
852
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
853
|
+
)
|
|
854
|
+
endMentionSession()
|
|
855
|
+
setSelection(start + token.length + 1)
|
|
856
|
+
refreshDisplaySpans(editable)
|
|
857
|
+
emitContentChanged()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private fun endMentionSession() {
|
|
861
|
+
if (!mentionActive) {
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
mentionActive = false
|
|
865
|
+
lastMentionQuery = null
|
|
866
|
+
emitEvent("topEditorMentionEnd") { putString("trigger", mentionTrigger) }
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private fun isWordBreak(c: Char): Boolean = c == ' ' || c == '\t' || c == '\n'
|
|
870
|
+
|
|
871
|
+
// Starts, updates, or ends the mention session from the text between the
|
|
872
|
+
// trigger and the caret.
|
|
873
|
+
private fun updateMentionSession() {
|
|
874
|
+
if (mentionTriggers.isEmpty()) {
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
val editable = text
|
|
878
|
+
val start = selectionStart
|
|
879
|
+
val end = selectionEnd
|
|
880
|
+
if (start != end || start < 0) {
|
|
881
|
+
endMentionSession()
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (mentionActive) {
|
|
886
|
+
var valid = mentionStart < editable.length && start > mentionStart
|
|
887
|
+
if (valid && mentionTrigger != editable[mentionStart].toString()) {
|
|
888
|
+
valid = false
|
|
889
|
+
}
|
|
890
|
+
var query = ""
|
|
891
|
+
if (valid) {
|
|
892
|
+
query = editable.substring(mentionStart + 1, start)
|
|
893
|
+
if (query.any { isWordBreak(it) }) {
|
|
894
|
+
valid = false
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (!valid) {
|
|
898
|
+
endMentionSession()
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
if (query == lastMentionQuery) {
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
lastMentionQuery = query
|
|
905
|
+
emitEvent("topEditorMentionChange") {
|
|
906
|
+
putString("query", query)
|
|
907
|
+
putString("trigger", mentionTrigger)
|
|
908
|
+
}
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (start == 0) {
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
val last = editable[start - 1].toString()
|
|
916
|
+
if (last !in mentionTriggers) {
|
|
917
|
+
return
|
|
918
|
+
}
|
|
919
|
+
if (start >= 2 && !isWordBreak(editable[start - 2])) {
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
mentionActive = true
|
|
923
|
+
mentionTrigger = last
|
|
924
|
+
mentionStart = start - 1
|
|
925
|
+
emitEvent("topEditorMentionStart") { putString("trigger", last) }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// After a word break lands, reports a bare URL the completed word forms.
|
|
929
|
+
private fun detectLinkBefore(position: Int) {
|
|
930
|
+
val editable = text
|
|
931
|
+
if (position > editable.length) {
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
var wordStart = position
|
|
935
|
+
while (wordStart > 0 && !isWordBreak(editable[wordStart - 1])) {
|
|
936
|
+
wordStart--
|
|
937
|
+
}
|
|
938
|
+
if (wordStart >= position) {
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
val word = editable.substring(wordStart, position)
|
|
942
|
+
if (!word.startsWith("http://") && !word.startsWith("https://")) {
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
if (word == "http://" || word == "https://") {
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
if (editable.getSpans(wordStart, position, LinkDataSpan::class.java).isNotEmpty()) {
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
// Linkify in place: a bare URL re-parses as an autolink in any markdown
|
|
952
|
+
// renderer, so the editor must show it as a link too (WYSIWYG). The app
|
|
953
|
+
// can still restyle or remove it from the onLinkDetected callback.
|
|
954
|
+
if (!lineIsCode(lineIndexAt(wordStart))) {
|
|
955
|
+
editable.setSpan(
|
|
956
|
+
LinkDataSpan(word, atomic = false),
|
|
957
|
+
wordStart,
|
|
958
|
+
position,
|
|
959
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
960
|
+
)
|
|
961
|
+
refreshDisplaySpansAround(editable, wordStart, position)
|
|
962
|
+
emitContentChanged()
|
|
963
|
+
}
|
|
964
|
+
emitEvent("topEditorLinkDetected") { putString("url", word) }
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Toggles a mark over the selection, or arms it for typed text. */
|
|
968
|
+
fun toggleMark(mark: Int) {
|
|
969
|
+
val start = selectionStart.coerceAtLeast(0)
|
|
970
|
+
val end = selectionEnd.coerceAtLeast(start)
|
|
971
|
+
// A code fence carries raw text only — marks applied there would render
|
|
972
|
+
// in the editor but silently vanish from the markdown, so refuse them.
|
|
973
|
+
for (line in lineIndexAt(start)..lineIndexAt(end)) {
|
|
974
|
+
if (EditorBlocks.type(lineBlocks.getOrElse(line) { 0 }) == EditorBlocks.CODE) {
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
// Superscript and subscript are mutually exclusive: a glyph cannot sit
|
|
979
|
+
// above and below the baseline, and combined they serialize to nested
|
|
980
|
+
// ^~…~^ that does not round-trip.
|
|
981
|
+
val exclusive = when (mark) {
|
|
982
|
+
EditorMarks.SUPERSCRIPT -> EditorMarks.SUBSCRIPT
|
|
983
|
+
EditorMarks.SUBSCRIPT -> EditorMarks.SUPERSCRIPT
|
|
984
|
+
else -> 0
|
|
985
|
+
}
|
|
986
|
+
if (start == end) {
|
|
987
|
+
pendingMarks = pendingMarks xor mark
|
|
988
|
+
if (pendingMarks and mark != 0) {
|
|
989
|
+
pendingMarks = pendingMarks and exclusive.inv()
|
|
990
|
+
}
|
|
991
|
+
pendingArmedAt = start
|
|
992
|
+
updateInputTypeForContext()
|
|
993
|
+
emitState()
|
|
994
|
+
return
|
|
995
|
+
}
|
|
996
|
+
val editable = text
|
|
997
|
+
if (commonMarksInRange(editable, start, end) and mark != 0) {
|
|
998
|
+
removeMark(editable, mark, start, end)
|
|
999
|
+
} else {
|
|
1000
|
+
if (exclusive != 0) {
|
|
1001
|
+
removeMark(editable, exclusive, start, end)
|
|
1002
|
+
}
|
|
1003
|
+
applyMark(editable, mark, start, end)
|
|
1004
|
+
}
|
|
1005
|
+
refreshDisplaySpans(editable)
|
|
1006
|
+
setSelection(start, end)
|
|
1007
|
+
emitContentChanged()
|
|
1008
|
+
emitState()
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Adds a mark over [start, end), merging with touching same-mark spans. */
|
|
1012
|
+
private fun applyMark(editable: Editable, mark: Int, start: Int, end: Int) {
|
|
1013
|
+
var newStart = start
|
|
1014
|
+
var newEnd = end
|
|
1015
|
+
for (span in editable.getSpans(start, end, EditorMarkSpan::class.java)) {
|
|
1016
|
+
if (span.mark != mark) {
|
|
1017
|
+
continue
|
|
1018
|
+
}
|
|
1019
|
+
newStart = minOf(newStart, editable.getSpanStart(span))
|
|
1020
|
+
newEnd = maxOf(newEnd, editable.getSpanEnd(span))
|
|
1021
|
+
editable.removeSpan(span)
|
|
1022
|
+
}
|
|
1023
|
+
editable.setSpan(
|
|
1024
|
+
EditorMarkSpan(mark),
|
|
1025
|
+
newStart,
|
|
1026
|
+
newEnd,
|
|
1027
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private fun removeMark(editable: Editable, mark: Int, start: Int, end: Int) {
|
|
1032
|
+
for (span in editable.getSpans(start, end, EditorMarkSpan::class.java)) {
|
|
1033
|
+
if (span.mark != mark) {
|
|
1034
|
+
continue
|
|
1035
|
+
}
|
|
1036
|
+
val spanStart = editable.getSpanStart(span)
|
|
1037
|
+
val spanEnd = editable.getSpanEnd(span)
|
|
1038
|
+
editable.removeSpan(span)
|
|
1039
|
+
if (spanStart < start) {
|
|
1040
|
+
editable.setSpan(
|
|
1041
|
+
EditorMarkSpan(mark),
|
|
1042
|
+
spanStart,
|
|
1043
|
+
start,
|
|
1044
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
1045
|
+
)
|
|
1046
|
+
}
|
|
1047
|
+
if (spanEnd > end) {
|
|
1048
|
+
editable.setSpan(
|
|
1049
|
+
EditorMarkSpan(mark),
|
|
1050
|
+
end,
|
|
1051
|
+
spanEnd,
|
|
1052
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
1053
|
+
)
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Marks present across the ENTIRE range (the AND). */
|
|
1059
|
+
private fun commonMarksInRange(editable: Editable, start: Int, end: Int): Int {
|
|
1060
|
+
var common = 0
|
|
1061
|
+
for (mark in EditorMarks.ALL) {
|
|
1062
|
+
val intervals = editable.getSpans(start, end, EditorMarkSpan::class.java)
|
|
1063
|
+
.filter { it.mark == mark }
|
|
1064
|
+
.map { editable.getSpanStart(it) to editable.getSpanEnd(it) }
|
|
1065
|
+
.sortedBy { it.first }
|
|
1066
|
+
var covered = start
|
|
1067
|
+
for ((spanStart, spanEnd) in intervals) {
|
|
1068
|
+
if (spanStart > covered) {
|
|
1069
|
+
break
|
|
1070
|
+
}
|
|
1071
|
+
covered = maxOf(covered, spanEnd)
|
|
1072
|
+
}
|
|
1073
|
+
if (covered >= end) {
|
|
1074
|
+
common = common or mark
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return common
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private fun marksAt(editable: Editable, position: Int): Int {
|
|
1081
|
+
if (position < 0 || position >= editable.length) {
|
|
1082
|
+
return 0
|
|
1083
|
+
}
|
|
1084
|
+
var flags = 0
|
|
1085
|
+
for (span in editable.getSpans(position, position + 1, EditorMarkSpan::class.java)) {
|
|
1086
|
+
if (editable.getSpanStart(span) <= position && editable.getSpanEnd(span) > position) {
|
|
1087
|
+
flags = flags or span.mark
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return flags
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Rebuilds the derived visual spans inside [regionStart, regionEnd):
|
|
1095
|
+
* inline spans from the data mark spans (boundary points partition the
|
|
1096
|
+
* text into constant-flag intervals), and per-line block spans from
|
|
1097
|
+
* lineBlocks. The default region is the whole document.
|
|
1098
|
+
*/
|
|
1099
|
+
private fun refreshDisplaySpans(
|
|
1100
|
+
editable: Editable,
|
|
1101
|
+
regionStart: Int = 0,
|
|
1102
|
+
regionEnd: Int = editable.length,
|
|
1103
|
+
) {
|
|
1104
|
+
for (span in editable.getSpans(regionStart, regionEnd, EditorDerivedSpan::class.java)) {
|
|
1105
|
+
if (editable.getSpanStart(span) < regionEnd && editable.getSpanEnd(span) > regionStart) {
|
|
1106
|
+
editable.removeSpan(span)
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
for (span in editable.getSpans(regionStart, regionEnd, LinkDataSpan::class.java)) {
|
|
1111
|
+
val start = editable.getSpanStart(span)
|
|
1112
|
+
val end = editable.getSpanEnd(span)
|
|
1113
|
+
if (start >= regionEnd || end <= regionStart) {
|
|
1114
|
+
continue
|
|
1115
|
+
}
|
|
1116
|
+
if (end > start) {
|
|
1117
|
+
editable.setSpan(
|
|
1118
|
+
LinkDisplaySpan(linkColor),
|
|
1119
|
+
start,
|
|
1120
|
+
end,
|
|
1121
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
val marks = editable.getSpans(regionStart, regionEnd, EditorMarkSpan::class.java)
|
|
1127
|
+
.filter { editable.getSpanStart(it) < regionEnd && editable.getSpanEnd(it) > regionStart }
|
|
1128
|
+
if (marks.isNotEmpty()) {
|
|
1129
|
+
// Sweep over sorted edges instead of rescanning every mark span per
|
|
1130
|
+
// interval (that scan was O(spans²) per keystroke).
|
|
1131
|
+
val starts = marks.map { editable.getSpanStart(it).coerceIn(0, editable.length) to it.mark }
|
|
1132
|
+
.sortedBy { it.first }
|
|
1133
|
+
val ends = marks.map { editable.getSpanEnd(it).coerceIn(0, editable.length) to it.mark }
|
|
1134
|
+
.sortedBy { it.first }
|
|
1135
|
+
val cuts = sortedSetOf(regionStart, regionEnd)
|
|
1136
|
+
for ((position, _) in starts) {
|
|
1137
|
+
cuts.add(position)
|
|
1138
|
+
}
|
|
1139
|
+
for ((position, _) in ends) {
|
|
1140
|
+
cuts.add(position)
|
|
1141
|
+
}
|
|
1142
|
+
val bitCounts = IntArray(32)
|
|
1143
|
+
var startIdx = 0
|
|
1144
|
+
var endIdx = 0
|
|
1145
|
+
var active = 0
|
|
1146
|
+
val points = cuts.toIntArray()
|
|
1147
|
+
for (i in 0 until points.size - 1) {
|
|
1148
|
+
val start = points[i]
|
|
1149
|
+
val end = points[i + 1]
|
|
1150
|
+
if (end <= start) {
|
|
1151
|
+
continue
|
|
1152
|
+
}
|
|
1153
|
+
while (endIdx < ends.size && ends[endIdx].first <= start) {
|
|
1154
|
+
val mark = ends[endIdx].second
|
|
1155
|
+
for (bit in 0 until 32) {
|
|
1156
|
+
if (mark and (1 shl bit) != 0 && --bitCounts[bit] == 0) {
|
|
1157
|
+
active = active and (1 shl bit).inv()
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
endIdx++
|
|
1161
|
+
}
|
|
1162
|
+
while (startIdx < starts.size && starts[startIdx].first <= start) {
|
|
1163
|
+
val mark = starts[startIdx].second
|
|
1164
|
+
for (bit in 0 until 32) {
|
|
1165
|
+
if (mark and (1 shl bit) != 0 && bitCounts[bit]++ == 0) {
|
|
1166
|
+
active = active or (1 shl bit)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
startIdx++
|
|
1170
|
+
}
|
|
1171
|
+
if (active != 0) {
|
|
1172
|
+
editable.setSpan(
|
|
1173
|
+
EditorDisplaySpan(active),
|
|
1174
|
+
start,
|
|
1175
|
+
end,
|
|
1176
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
1177
|
+
)
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
ensureLineBlocks()
|
|
1183
|
+
var lineStart = lineStartOffset(lineIndexAt(regionStart))
|
|
1184
|
+
var index = lineIndexAt(regionStart)
|
|
1185
|
+
// Ordered markers number from the start of their contiguous group,
|
|
1186
|
+
// which may sit before the region; recover the count from lineBlocks.
|
|
1187
|
+
var orderedNumber = 0
|
|
1188
|
+
var probe = index - 1
|
|
1189
|
+
while (probe >= 0 && EditorBlocks.type(lineBlocks.getOrElse(probe) { 0 }) == EditorBlocks.ORDERED) {
|
|
1190
|
+
orderedNumber++
|
|
1191
|
+
probe--
|
|
1192
|
+
}
|
|
1193
|
+
while (true) {
|
|
1194
|
+
val newline = editable.indexOf('\n', lineStart)
|
|
1195
|
+
val lineEnd = if (newline == -1) editable.length else newline
|
|
1196
|
+
val block = lineBlocks.getOrElse(index) { 0 }
|
|
1197
|
+
val type = EditorBlocks.type(block)
|
|
1198
|
+
orderedNumber = if (type == EditorBlocks.ORDERED) orderedNumber + 1 else 0
|
|
1199
|
+
if (lineEnd > lineStart) {
|
|
1200
|
+
val span: Any? = when (type) {
|
|
1201
|
+
EditorBlocks.HEADING -> HeadingDisplaySpan(EditorBlocks.level(block))
|
|
1202
|
+
EditorBlocks.QUOTE -> QuoteDisplaySpan(density)
|
|
1203
|
+
EditorBlocks.CODE -> CodeLineDisplaySpan()
|
|
1204
|
+
EditorBlocks.BULLET -> ListMarkerDisplaySpan("•", density)
|
|
1205
|
+
EditorBlocks.ORDERED -> ListMarkerDisplaySpan("$orderedNumber.", density)
|
|
1206
|
+
else -> null
|
|
1207
|
+
}
|
|
1208
|
+
if (span != null) {
|
|
1209
|
+
editable.setSpan(span, lineStart, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (newline != -1) {
|
|
1213
|
+
// The heading's newline must not extend the heading's font run, or
|
|
1214
|
+
// the trailing empty line's caret inherits heading metrics.
|
|
1215
|
+
if (type == EditorBlocks.HEADING) {
|
|
1216
|
+
editable.setSpan(
|
|
1217
|
+
NewlineResetSpan(),
|
|
1218
|
+
newline,
|
|
1219
|
+
newline + 1,
|
|
1220
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
1221
|
+
)
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
// Line height from the styles cascade: headings/code use their
|
|
1225
|
+
// element style, everything else base/paragraph (0 = natural).
|
|
1226
|
+
val lineHeight = when (type) {
|
|
1227
|
+
EditorBlocks.HEADING -> headingLineHeightsPx[EditorBlocks.level(block).coerceIn(1, 6)]
|
|
1228
|
+
EditorBlocks.CODE -> codeLineHeightPx
|
|
1229
|
+
else -> lineHeightPx
|
|
1230
|
+
}
|
|
1231
|
+
val spanEnd = if (newline == -1) lineEnd else newline + 1
|
|
1232
|
+
if (lineHeight > 0 && spanEnd > lineStart) {
|
|
1233
|
+
editable.setSpan(
|
|
1234
|
+
EditorLineHeightSpan(lineHeight),
|
|
1235
|
+
lineStart,
|
|
1236
|
+
spanEnd,
|
|
1237
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
if (newline == -1 || newline + 1 >= regionEnd) {
|
|
1241
|
+
break
|
|
1242
|
+
}
|
|
1243
|
+
lineStart = newline + 1
|
|
1244
|
+
index++
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Scoped refresh for plain in-line keystrokes: expands the edit to whole
|
|
1250
|
+
* lines plus any span it touches, so derived spans elsewhere (and their
|
|
1251
|
+
* DynamicLayout reflows) are left alone. Structural edits (newlines,
|
|
1252
|
+
* empty doc) take the full-document path — ordered-list renumbering can
|
|
1253
|
+
* ripple arbitrarily far.
|
|
1254
|
+
*/
|
|
1255
|
+
private fun refreshDisplaySpansAround(editable: Editable, editStart: Int, editEnd: Int) {
|
|
1256
|
+
var start = editStart.coerceIn(0, editable.length)
|
|
1257
|
+
var end = editEnd.coerceIn(start, editable.length)
|
|
1258
|
+
while (true) {
|
|
1259
|
+
var expandedStart = start
|
|
1260
|
+
var expandedEnd = end
|
|
1261
|
+
// getSpans also returns spans merely TOUCHING the probe; absorbing
|
|
1262
|
+
// those would chain across adjacent line-height spans to the whole
|
|
1263
|
+
// document, so only strict overlaps expand the region.
|
|
1264
|
+
for (span in editable.getSpans(start, end, EditorDerivedSpan::class.java)) {
|
|
1265
|
+
if (editable.getSpanStart(span) < end && editable.getSpanEnd(span) > start) {
|
|
1266
|
+
expandedStart = minOf(expandedStart, editable.getSpanStart(span))
|
|
1267
|
+
expandedEnd = maxOf(expandedEnd, editable.getSpanEnd(span))
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
for (span in editable.getSpans(start, end, EditorMarkSpan::class.java)) {
|
|
1271
|
+
if (editable.getSpanStart(span) < end && editable.getSpanEnd(span) > start) {
|
|
1272
|
+
expandedStart = minOf(expandedStart, editable.getSpanStart(span))
|
|
1273
|
+
expandedEnd = maxOf(expandedEnd, editable.getSpanEnd(span))
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
for (span in editable.getSpans(start, end, LinkDataSpan::class.java)) {
|
|
1277
|
+
if (editable.getSpanStart(span) < end && editable.getSpanEnd(span) > start) {
|
|
1278
|
+
expandedStart = minOf(expandedStart, editable.getSpanStart(span))
|
|
1279
|
+
expandedEnd = maxOf(expandedEnd, editable.getSpanEnd(span))
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
while (expandedStart > 0 && editable[expandedStart - 1] != '\n') {
|
|
1283
|
+
expandedStart--
|
|
1284
|
+
}
|
|
1285
|
+
while (expandedEnd < editable.length && editable[expandedEnd] != '\n') {
|
|
1286
|
+
expandedEnd++
|
|
1287
|
+
}
|
|
1288
|
+
if (expandedEnd < editable.length) {
|
|
1289
|
+
// Include the newline: line-height spans cover it.
|
|
1290
|
+
expandedEnd++
|
|
1291
|
+
}
|
|
1292
|
+
if (expandedStart == start && expandedEnd == end) {
|
|
1293
|
+
break
|
|
1294
|
+
}
|
|
1295
|
+
start = expandedStart.coerceIn(0, editable.length)
|
|
1296
|
+
end = expandedEnd.coerceIn(start, editable.length)
|
|
1297
|
+
}
|
|
1298
|
+
refreshDisplaySpans(editable, start, end)
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
private fun serializedMarkdown(): String {
|
|
1302
|
+
val editable = text
|
|
1303
|
+
val spans = editable.getSpans(0, editable.length, EditorMarkSpan::class.java)
|
|
1304
|
+
val runs = IntArray(spans.size * 3)
|
|
1305
|
+
for ((index, span) in spans.withIndex()) {
|
|
1306
|
+
runs[index * 3] = editable.getSpanStart(span)
|
|
1307
|
+
runs[index * 3 + 1] = editable.getSpanEnd(span)
|
|
1308
|
+
runs[index * 3 + 2] = span.mark
|
|
1309
|
+
}
|
|
1310
|
+
ensureLineBlocks()
|
|
1311
|
+
val blocks = IntArray(lineBlocks.size * 2)
|
|
1312
|
+
for ((index, block) in lineBlocks.withIndex()) {
|
|
1313
|
+
blocks[index * 2] = EditorBlocks.type(block)
|
|
1314
|
+
blocks[index * 2 + 1] = EditorBlocks.level(block)
|
|
1315
|
+
}
|
|
1316
|
+
val links = editable.getSpans(0, editable.length, LinkDataSpan::class.java)
|
|
1317
|
+
.sortedBy { editable.getSpanStart(it) }
|
|
1318
|
+
val linkRanges = IntArray(links.size * 2)
|
|
1319
|
+
val linkUrls = ArrayList<String>(links.size)
|
|
1320
|
+
for ((index, span) in links.withIndex()) {
|
|
1321
|
+
linkRanges[index * 2] = editable.getSpanStart(span)
|
|
1322
|
+
linkRanges[index * 2 + 1] = editable.getSpanEnd(span)
|
|
1323
|
+
linkUrls.add(span.url)
|
|
1324
|
+
}
|
|
1325
|
+
return JetMarkdownNative.markdownFromEditor(
|
|
1326
|
+
editable.toString(),
|
|
1327
|
+
runs,
|
|
1328
|
+
blocks,
|
|
1329
|
+
linkRanges,
|
|
1330
|
+
linkUrls,
|
|
1331
|
+
)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
private var markdownEmitPending = false
|
|
1335
|
+
|
|
1336
|
+
private fun emitContentChanged() {
|
|
1337
|
+
publishHeight()
|
|
1338
|
+
emitEvent("topEditorChangeText") { putString("text", text.toString()) }
|
|
1339
|
+
// Serializing the whole document (spans + JNI round trip) per keystroke
|
|
1340
|
+
// is the expensive half; coalesce bursts to one emission per frame.
|
|
1341
|
+
if (!markdownEmitPending) {
|
|
1342
|
+
markdownEmitPending = true
|
|
1343
|
+
post {
|
|
1344
|
+
markdownEmitPending = false
|
|
1345
|
+
if (stateWrapper != null) {
|
|
1346
|
+
emitEvent("topEditorChangeMarkdown") { putString("markdown", serializedMarkdown()) }
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
private fun emitState() {
|
|
1353
|
+
val start = selectionStart.coerceAtLeast(0)
|
|
1354
|
+
val end = selectionEnd.coerceAtLeast(start)
|
|
1355
|
+
val flags = if (start == end) pendingMarks else commonMarksInRange(text, start, end)
|
|
1356
|
+
val block = lineBlocks.getOrElse(lineIndexAt(start)) { 0 }
|
|
1357
|
+
val stateKey = (block.toLong() shl 32) or flags.toLong()
|
|
1358
|
+
if (stateKey == lastStateKey) {
|
|
1359
|
+
return
|
|
1360
|
+
}
|
|
1361
|
+
lastStateKey = stateKey
|
|
1362
|
+
val type = EditorBlocks.type(block)
|
|
1363
|
+
emitEvent("topEditorChangeState") {
|
|
1364
|
+
putInt(
|
|
1365
|
+
"headingLevel",
|
|
1366
|
+
if (type == EditorBlocks.HEADING) EditorBlocks.level(block) else 0,
|
|
1367
|
+
)
|
|
1368
|
+
putBoolean("isBlockQuote", type == EditorBlocks.QUOTE)
|
|
1369
|
+
putBoolean("isBold", flags and EditorMarks.BOLD != 0)
|
|
1370
|
+
putBoolean("isCodeBlock", type == EditorBlocks.CODE)
|
|
1371
|
+
putBoolean("isInlineCode", flags and EditorMarks.INLINE_CODE != 0)
|
|
1372
|
+
putBoolean("isItalic", flags and EditorMarks.ITALIC != 0)
|
|
1373
|
+
putBoolean("isOrderedList", type == EditorBlocks.ORDERED)
|
|
1374
|
+
putBoolean("isSpoiler", flags and EditorMarks.SPOILER != 0)
|
|
1375
|
+
putBoolean("isStrikethrough", flags and EditorMarks.STRIKETHROUGH != 0)
|
|
1376
|
+
putBoolean("isSubscript", flags and EditorMarks.SUBSCRIPT != 0)
|
|
1377
|
+
putBoolean("isSuperscript", flags and EditorMarks.SUPERSCRIPT != 0)
|
|
1378
|
+
putBoolean("isUnorderedList", type == EditorBlocks.BULLET)
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
fun setMultiline(value: Boolean) {
|
|
1383
|
+
if (multiline != value) {
|
|
1384
|
+
multiline = value
|
|
1385
|
+
applyInputType()
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
fun setAutoCorrectEnabled(value: Boolean) {
|
|
1390
|
+
if (autoCorrectEnabled != value) {
|
|
1391
|
+
autoCorrectEnabled = value
|
|
1392
|
+
applyInputType()
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
fun setCapitalizeMode(value: String?) {
|
|
1397
|
+
val mode = value ?: "sentences"
|
|
1398
|
+
if (capitalizeMode != mode) {
|
|
1399
|
+
capitalizeMode = mode
|
|
1400
|
+
applyInputType()
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
fun setPlaceholderText(value: String?) {
|
|
1405
|
+
if (placeholderText != value) {
|
|
1406
|
+
placeholderText = value
|
|
1407
|
+
// Fabric never renders TextView's own hint (drawn manually in
|
|
1408
|
+
// onDraw), but TalkBack still announces it.
|
|
1409
|
+
hint = value
|
|
1410
|
+
invalidate()
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
fun setPlaceholderColor(value: Int) {
|
|
1415
|
+
val next = if (value != 0) value else DEFAULT_PLACEHOLDER_COLOR
|
|
1416
|
+
if (placeholderColor != next) {
|
|
1417
|
+
placeholderColor = next
|
|
1418
|
+
invalidate()
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private val decorationPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
1423
|
+
private val decorationTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
1424
|
+
private val placeholderMetrics = Paint.FontMetrics()
|
|
1425
|
+
|
|
1426
|
+
override fun onDraw(canvas: Canvas) {
|
|
1427
|
+
drawBlockDecorations(canvas)
|
|
1428
|
+
super.onDraw(canvas)
|
|
1429
|
+
val hint = placeholderText
|
|
1430
|
+
if (!hint.isNullOrEmpty() && text.isEmpty()) {
|
|
1431
|
+
placeholderPaint.textSize = textSize
|
|
1432
|
+
placeholderPaint.typeface = typeface
|
|
1433
|
+
placeholderPaint.color = placeholderColor
|
|
1434
|
+
placeholderPaint.getFontMetrics(placeholderMetrics)
|
|
1435
|
+
canvas.drawText(
|
|
1436
|
+
hint,
|
|
1437
|
+
compoundPaddingLeft.toFloat(),
|
|
1438
|
+
compoundPaddingTop - placeholderMetrics.top,
|
|
1439
|
+
placeholderPaint,
|
|
1440
|
+
)
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Block visuals spans cannot provide: the full-width code stripe (drawn
|
|
1446
|
+
* per contiguous code GROUP, empty lines included) and markers/bars for
|
|
1447
|
+
* empty block lines (spans need at least one character).
|
|
1448
|
+
*/
|
|
1449
|
+
private fun drawBlockDecorations(canvas: Canvas) {
|
|
1450
|
+
val textLayout = layout ?: return
|
|
1451
|
+
// Plain-paragraph documents (the common case) draw nothing; skip the
|
|
1452
|
+
// per-line text scan the cursor-blink invalidations would otherwise pay.
|
|
1453
|
+
if (lineBlocks.none { it != 0 }) {
|
|
1454
|
+
return
|
|
1455
|
+
}
|
|
1456
|
+
val offsetY = compoundPaddingTop.toFloat()
|
|
1457
|
+
val contentLeft = compoundPaddingLeft.toFloat()
|
|
1458
|
+
val stripeLeft = (contentLeft - 6 * density).coerceAtLeast(0f)
|
|
1459
|
+
val stripeRight =
|
|
1460
|
+
(width - compoundPaddingRight + 6 * density).coerceAtMost(width.toFloat())
|
|
1461
|
+
val markerColor = (currentTextColor and 0x00FFFFFF) or -0x67000000
|
|
1462
|
+
|
|
1463
|
+
var lineStart = 0
|
|
1464
|
+
var index = 0
|
|
1465
|
+
var orderedNumber = 0
|
|
1466
|
+
var groupTop = -1f
|
|
1467
|
+
var groupBottom = 0f
|
|
1468
|
+
|
|
1469
|
+
fun flushCodeGroup() {
|
|
1470
|
+
if (groupTop >= 0f) {
|
|
1471
|
+
decorationPaint.color = 0x14808080
|
|
1472
|
+
canvas.drawRoundRect(
|
|
1473
|
+
stripeLeft,
|
|
1474
|
+
groupTop - 2 * density,
|
|
1475
|
+
stripeRight,
|
|
1476
|
+
groupBottom + 2 * density,
|
|
1477
|
+
6 * density,
|
|
1478
|
+
6 * density,
|
|
1479
|
+
decorationPaint,
|
|
1480
|
+
)
|
|
1481
|
+
groupTop = -1f
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
while (true) {
|
|
1486
|
+
val newline = text.indexOf('\n', lineStart)
|
|
1487
|
+
val lineEnd = if (newline == -1) text.length else newline
|
|
1488
|
+
val block = lineBlocks.getOrElse(index) { 0 }
|
|
1489
|
+
val type = EditorBlocks.type(block)
|
|
1490
|
+
orderedNumber = if (type == EditorBlocks.ORDERED) orderedNumber + 1 else 0
|
|
1491
|
+
|
|
1492
|
+
val layoutLine = textLayout.getLineForOffset(lineStart)
|
|
1493
|
+
val top = textLayout.getLineTop(layoutLine) + offsetY
|
|
1494
|
+
val bottom =
|
|
1495
|
+
textLayout.getLineBottom(textLayout.getLineForOffset(lineEnd)) + offsetY
|
|
1496
|
+
|
|
1497
|
+
if (type == EditorBlocks.CODE) {
|
|
1498
|
+
if (groupTop < 0f) {
|
|
1499
|
+
groupTop = top
|
|
1500
|
+
}
|
|
1501
|
+
groupBottom = bottom
|
|
1502
|
+
} else {
|
|
1503
|
+
flushCodeGroup()
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (lineEnd == lineStart && block != 0) {
|
|
1507
|
+
when (type) {
|
|
1508
|
+
EditorBlocks.QUOTE -> {
|
|
1509
|
+
decorationPaint.color = markerColor
|
|
1510
|
+
val barLeft = contentLeft + 4 * density
|
|
1511
|
+
canvas.drawRect(barLeft, top, barLeft + 3 * density, bottom, decorationPaint)
|
|
1512
|
+
}
|
|
1513
|
+
EditorBlocks.BULLET, EditorBlocks.ORDERED -> {
|
|
1514
|
+
val marker = if (type == EditorBlocks.BULLET) "•" else "$orderedNumber."
|
|
1515
|
+
decorationTextPaint.textSize = textSize
|
|
1516
|
+
decorationTextPaint.typeface = typeface
|
|
1517
|
+
decorationTextPaint.color = markerColor
|
|
1518
|
+
val markerWidth = decorationTextPaint.measureText(marker)
|
|
1519
|
+
canvas.drawText(
|
|
1520
|
+
marker,
|
|
1521
|
+
contentLeft + (24 - 6) * density - markerWidth,
|
|
1522
|
+
textLayout.getLineBaseline(layoutLine) + offsetY,
|
|
1523
|
+
decorationTextPaint,
|
|
1524
|
+
)
|
|
1525
|
+
}
|
|
1526
|
+
else -> Unit
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (newline == -1) {
|
|
1531
|
+
break
|
|
1532
|
+
}
|
|
1533
|
+
lineStart = newline + 1
|
|
1534
|
+
index++
|
|
1535
|
+
}
|
|
1536
|
+
flushCodeGroup()
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
fun setCursorColorInt(value: Int) {
|
|
1540
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
1541
|
+
if (value != 0) {
|
|
1542
|
+
textCursorDrawable?.setTint(value)
|
|
1543
|
+
} else {
|
|
1544
|
+
textCursorDrawable?.setTintList(null)
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
fun setSelectionColorInt(value: Int) {
|
|
1550
|
+
highlightColor = if (value != 0) value else defaultHighlightColor
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
fun setAutoFocus(value: Boolean) {
|
|
1554
|
+
pendingAutoFocus = value
|
|
1555
|
+
if (value && isAttachedToWindow) {
|
|
1556
|
+
focusAndShowKeyboard()
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
fun setEditorSelection(start: Int, end: Int) {
|
|
1561
|
+
val length = text.length
|
|
1562
|
+
val clampedStart = start.coerceIn(0, length)
|
|
1563
|
+
val clampedEnd = end.coerceIn(clampedStart, length)
|
|
1564
|
+
setSelection(clampedStart, clampedEnd)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
fun focusAndShowKeyboard() {
|
|
1568
|
+
requestFocus()
|
|
1569
|
+
val manager =
|
|
1570
|
+
context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
1571
|
+
manager?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
fun blurAndHideKeyboard() {
|
|
1575
|
+
clearFocus()
|
|
1576
|
+
val manager =
|
|
1577
|
+
context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
1578
|
+
manager?.hideSoftInputFromWindow(windowToken, 0)
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
fun resetForRecycle() {
|
|
1582
|
+
// The next tenant's props arrive after this; every prop-backed field
|
|
1583
|
+
// must return to its default or state leaks between list items. The
|
|
1584
|
+
// clear runs watcher-silent: emitting change events for a view being
|
|
1585
|
+
// recycled would reach the OLD component's JS handlers.
|
|
1586
|
+
stateWrapper = null
|
|
1587
|
+
suppressWatcher = true
|
|
1588
|
+
setText("")
|
|
1589
|
+
suppressWatcher = false
|
|
1590
|
+
stylesJson = ""
|
|
1591
|
+
defaultValueApplied = false
|
|
1592
|
+
pendingAutoFocus = false
|
|
1593
|
+
lastPublishedHeight = 0f
|
|
1594
|
+
maxHeightPx = 0
|
|
1595
|
+
contentExceedsMax = false
|
|
1596
|
+
scrollAllowed = true
|
|
1597
|
+
isVerticalScrollBarEnabled = false
|
|
1598
|
+
pendingMarks = 0
|
|
1599
|
+
pendingArmedAt = -1
|
|
1600
|
+
lastStateKey = -1L
|
|
1601
|
+
lineBlocks.clear()
|
|
1602
|
+
ensureLineBlocks()
|
|
1603
|
+
mentionActive = false
|
|
1604
|
+
lastMentionQuery = null
|
|
1605
|
+
mentionTriggers = emptyList()
|
|
1606
|
+
placeholderText = null
|
|
1607
|
+
hint = null
|
|
1608
|
+
placeholderColor = DEFAULT_PLACEHOLDER_COLOR
|
|
1609
|
+
linkColor = DEFAULT_LINK_COLOR
|
|
1610
|
+
isEnabled = true
|
|
1611
|
+
highlightColor = defaultHighlightColor
|
|
1612
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
1613
|
+
textCursorDrawable?.setTintList(null)
|
|
1614
|
+
}
|
|
1615
|
+
val inputDirty = codeContextActive || !multiline || !autoCorrectEnabled ||
|
|
1616
|
+
capitalizeMode != "sentences"
|
|
1617
|
+
codeContextActive = false
|
|
1618
|
+
multiline = true
|
|
1619
|
+
autoCorrectEnabled = true
|
|
1620
|
+
capitalizeMode = "sentences"
|
|
1621
|
+
if (inputDirty) {
|
|
1622
|
+
applyInputType()
|
|
1623
|
+
}
|
|
1624
|
+
applyTextStyles()
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
override fun onAttachedToWindow() {
|
|
1628
|
+
super.onAttachedToWindow()
|
|
1629
|
+
if (pendingAutoFocus) {
|
|
1630
|
+
pendingAutoFocus = false
|
|
1631
|
+
post { focusAndShowKeyboard() }
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
|
1636
|
+
super.onSelectionChanged(selStart, selEnd)
|
|
1637
|
+
if (!constructed) {
|
|
1638
|
+
return
|
|
1639
|
+
}
|
|
1640
|
+
// Sticky typing state: inherit the marks of the character before the
|
|
1641
|
+
// caret. Skipped mid-edit — afterTextChanged has not applied the pending
|
|
1642
|
+
// marks to the inserted text yet, so reading here would clear them.
|
|
1643
|
+
// Marks end at the paragraph break: a newline never carries them
|
|
1644
|
+
// forward.
|
|
1645
|
+
if (!editInProgress && selStart == selEnd && text != null &&
|
|
1646
|
+
selStart != pendingArmedAt
|
|
1647
|
+
) {
|
|
1648
|
+
pendingArmedAt = -1
|
|
1649
|
+
pendingMarks = if (selStart > 0 && text[selStart - 1] == '\n') {
|
|
1650
|
+
0
|
|
1651
|
+
} else {
|
|
1652
|
+
marksAt(text, selStart - 1)
|
|
1653
|
+
}
|
|
1654
|
+
updateInputTypeForContext()
|
|
1655
|
+
}
|
|
1656
|
+
if (!editInProgress) {
|
|
1657
|
+
updateMentionSession()
|
|
1658
|
+
}
|
|
1659
|
+
emitState()
|
|
1660
|
+
emitEvent("topEditorChangeSelection") {
|
|
1661
|
+
putInt("start", selStart)
|
|
1662
|
+
putInt("end", selEnd)
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
1667
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
1668
|
+
publishHeight()
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
fun setMaxContentHeight(dp: Double) {
|
|
1672
|
+
val px = (dp * density).toInt()
|
|
1673
|
+
if (px != maxHeightPx) {
|
|
1674
|
+
maxHeightPx = px
|
|
1675
|
+
publishHeight()
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
|
|
1680
|
+
// While capped, drags scroll the editor's own content — the parent
|
|
1681
|
+
// scroll view must not steal them.
|
|
1682
|
+
if (event.actionMasked == android.view.MotionEvent.ACTION_DOWN &&
|
|
1683
|
+
contentExceedsMax && scrollAllowed
|
|
1684
|
+
) {
|
|
1685
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
1686
|
+
}
|
|
1687
|
+
return super.onTouchEvent(event)
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
fun setScrollAllowed(value: Boolean) {
|
|
1691
|
+
if (scrollAllowed != value) {
|
|
1692
|
+
scrollAllowed = value
|
|
1693
|
+
isVerticalScrollBarEnabled = contentExceedsMax && scrollAllowed
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
private fun publishHeight() {
|
|
1698
|
+
val wrapper = stateWrapper ?: return
|
|
1699
|
+
val currentWidth = width
|
|
1700
|
+
if (currentWidth <= 0) {
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
measure(
|
|
1704
|
+
MeasureSpec.makeMeasureSpec(currentWidth, MeasureSpec.EXACTLY),
|
|
1705
|
+
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
|
|
1706
|
+
)
|
|
1707
|
+
var heightPx = measuredHeight
|
|
1708
|
+
contentExceedsMax = maxHeightPx in 1 until heightPx
|
|
1709
|
+
if (contentExceedsMax) {
|
|
1710
|
+
heightPx = maxHeightPx
|
|
1711
|
+
}
|
|
1712
|
+
isVerticalScrollBarEnabled = contentExceedsMax && scrollAllowed
|
|
1713
|
+
val heightDp = heightPx / density
|
|
1714
|
+
if (kotlin.math.abs(heightDp - lastPublishedHeight) < 0.5f) {
|
|
1715
|
+
return
|
|
1716
|
+
}
|
|
1717
|
+
lastPublishedHeight = heightDp
|
|
1718
|
+
wrapper.updateState(Arguments.createMap().apply { putDouble("height", heightDp.toDouble()) })
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
private companion object {
|
|
1722
|
+
const val DEFAULT_PLACEHOLDER_COLOR = 0x4D000000
|
|
1723
|
+
const val DEFAULT_LINK_COLOR = -0xbd5a0b // #4285F5-ish; styles override
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
private inline fun emitEvent(name: String, crossinline builder: WritableMap.() -> Unit) {
|
|
1727
|
+
val reactContext = context as? ReactContext ?: return
|
|
1728
|
+
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return
|
|
1729
|
+
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
|
|
1730
|
+
dispatcher.dispatchEvent(object : Event<Nothing>(surfaceId, id) {
|
|
1731
|
+
override fun getEventName(): String = name
|
|
1732
|
+
|
|
1733
|
+
override fun getEventData(): WritableMap = Arguments.createMap().apply(builder)
|
|
1734
|
+
})
|
|
1735
|
+
}
|
|
1736
|
+
}
|