react-native-jet-markdown 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +4 -56
  2. package/android/src/main/cpp/JniBindings.cpp +0 -106
  3. package/android/src/main/java/com/jetmarkdown/JetMarkdownNative.kt +0 -79
  4. package/android/src/main/java/com/jetmarkdown/JetMarkdownPackage.kt +1 -2
  5. package/android/src/main/java/com/jetmarkdown/render/spans/MarkdownLineHeightSpan.kt +2 -13
  6. package/cpp/react/override/react/renderer/components/JetMarkdownViewSpec/ComponentDescriptors.h +0 -4
  7. package/cpp/tests/parser_tests.cpp +0 -402
  8. package/cpp/tests/run_tests.sh +0 -3
  9. package/lib/module/index.js +0 -2
  10. package/lib/module/index.js.map +1 -1
  11. package/lib/typescript/src/index.d.ts +1 -3
  12. package/lib/typescript/src/index.d.ts.map +1 -1
  13. package/lib/typescript/src/types.d.ts +0 -127
  14. package/lib/typescript/src/types.d.ts.map +1 -1
  15. package/package.json +2 -5
  16. package/src/index.tsx +0 -9
  17. package/src/types.ts +0 -134
  18. package/android/src/main/java/com/jetmarkdown/editor/EditorSpans.kt +0 -217
  19. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorManager.kt +0 -224
  20. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorView.kt +0 -1736
  21. package/cpp/core/AstToMarkdown.cpp +0 -549
  22. package/cpp/core/AstToMarkdown.h +0 -16
  23. package/cpp/core/EditorRuns.cpp +0 -640
  24. package/cpp/core/EditorRuns.h +0 -94
  25. package/cpp/core/EditorText.cpp +0 -15
  26. package/cpp/core/EditorText.h +0 -18
  27. package/cpp/react/JetMarkdownEditorShadowNode.cpp +0 -54
  28. package/cpp/react/JetMarkdownEditorShadowNode.h +0 -55
  29. package/cpp/react/JetMarkdownEditorState.h +0 -35
  30. package/ios/editor/JetMarkdownEditor.h +0 -12
  31. package/ios/editor/JetMarkdownEditor.mm +0 -2002
  32. package/lib/module/JetMarkdownEditor.js +0 -85
  33. package/lib/module/JetMarkdownEditor.js.map +0 -1
  34. package/lib/module/JetMarkdownEditor.native.js +0 -244
  35. package/lib/module/JetMarkdownEditor.native.js.map +0 -1
  36. package/lib/module/JetMarkdownEditorNativeComponent.ts +0 -173
  37. package/lib/module/useJetMarkdownEditor.js +0 -41
  38. package/lib/module/useJetMarkdownEditor.js.map +0 -1
  39. package/lib/typescript/src/JetMarkdownEditor.d.ts +0 -6
  40. package/lib/typescript/src/JetMarkdownEditor.d.ts.map +0 -1
  41. package/lib/typescript/src/JetMarkdownEditor.native.d.ts +0 -6
  42. package/lib/typescript/src/JetMarkdownEditor.native.d.ts.map +0 -1
  43. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts +0 -105
  44. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts.map +0 -1
  45. package/lib/typescript/src/useJetMarkdownEditor.d.ts +0 -36
  46. package/lib/typescript/src/useJetMarkdownEditor.d.ts.map +0 -1
  47. package/src/JetMarkdownEditor.native.tsx +0 -301
  48. package/src/JetMarkdownEditor.tsx +0 -90
  49. package/src/JetMarkdownEditorNativeComponent.ts +0 -173
  50. package/src/useJetMarkdownEditor.ts +0 -50
@@ -1,1736 +0,0 @@
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
- }