@chaitrabhairappa/react-native-rich-text-editor 2.1.1 → 3.0.0
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/README.md +60 -4
- package/android/src/main/java/com/richtext/editor/FloatingToolbar.kt +7 -0
- package/android/src/main/java/com/richtext/editor/MediaAttachmentSpan.kt +85 -0
- package/android/src/main/java/com/richtext/editor/MediaAttachmentSupport.kt +296 -0
- package/android/src/main/java/com/richtext/editor/RichTextEditorView.kt +325 -41
- package/android/src/main/java/com/richtext/editor/RichTextEditorViewManager.kt +37 -0
- package/android/src/main/res/drawable/ic_format_media_attachment.xml +9 -0
- package/ios/RichTextEditorView.swift +430 -40
- package/ios/RichTextEditorViewManager.m +1 -0
- package/ios/RichTextEditorViewManager.swift +8 -0
- package/ios/ToolbarIcons.swift +24 -0
- package/lib/commonjs/index.js +44 -16
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/types.js +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/module/index.js +47 -18
- package/lib/module/index.js.map +1 -1
- package/lib/module/types.js +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -4
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +16 -7
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.tsx +175 -94
- package/src/types.ts +72 -46
package/README.md
CHANGED
|
@@ -20,12 +20,13 @@ Unlike other rich text editor packages that rely on HTML and WebView, this libra
|
|
|
20
20
|
- Bullet lists and Numbered lists
|
|
21
21
|
- Headings
|
|
22
22
|
- Quotes and Checklists
|
|
23
|
+
- Media attachments (images)
|
|
23
24
|
- Link insertion
|
|
24
25
|
- Undo/Redo
|
|
25
26
|
- Text alignment (left, center, right)
|
|
26
27
|
- Indent/Outdent
|
|
27
28
|
- Floating toolbar with customizable options
|
|
28
|
-
-
|
|
29
|
+
- Three variants: outlined, flat, and plain
|
|
29
30
|
- Auto-growing height
|
|
30
31
|
- **`numberOfLines` support** — truncate text with ellipsis in readOnly mode
|
|
31
32
|
- **Delta-based content updates** for optimized performance
|
|
@@ -143,7 +144,7 @@ export default App;
|
|
|
143
144
|
| `maxHeight` | `number` | `undefined` | Maximum height before scrolling |
|
|
144
145
|
| `showToolbar` | `boolean` | `true` | Show/hide floating toolbar |
|
|
145
146
|
| `toolbarOptions` | `ToolbarOption[]` | All options | Customize toolbar buttons |
|
|
146
|
-
| `variant` | `'outlined' \| 'flat'`
|
|
147
|
+
| `variant` | `'outlined' \| 'flat' \| 'plain'` | `'outlined'` | Editor style variant |
|
|
147
148
|
| `onContentChange` | `(event: ContentChangeEvent) => void` | `undefined` | Called when content changes |
|
|
148
149
|
| `onSelectionChange` | `(event: SelectionChangeEvent) => void` | `undefined` | Called when selection changes |
|
|
149
150
|
| `onFocus` | `() => void` | `undefined` | Called when editor gains focus |
|
|
@@ -180,6 +181,9 @@ editorRef.current?.setQuote();
|
|
|
180
181
|
editorRef.current?.setChecklist();
|
|
181
182
|
editorRef.current?.setParagraph();
|
|
182
183
|
|
|
184
|
+
// Media
|
|
185
|
+
editorRef.current?.insertMediaAttachment({ kind: 'image', uri: 'https://example.com/image.png' });
|
|
186
|
+
|
|
183
187
|
// Actions
|
|
184
188
|
editorRef.current?.insertLink(url, text);
|
|
185
189
|
editorRef.current?.undo();
|
|
@@ -204,9 +208,18 @@ interface Block {
|
|
|
204
208
|
alignment?: TextAlignment;
|
|
205
209
|
checked?: boolean;
|
|
206
210
|
indentLevel?: number;
|
|
211
|
+
mediaAttachment?: MediaAttachment;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface MediaAttachment {
|
|
215
|
+
kind: 'image';
|
|
216
|
+
uri: string;
|
|
217
|
+
width?: number;
|
|
218
|
+
height?: number;
|
|
219
|
+
alt?: string;
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
type BlockType = 'paragraph' | 'bullet' | 'numbered' | 'heading' | 'quote' | 'checklist';
|
|
222
|
+
type BlockType = 'paragraph' | 'bullet' | 'numbered' | 'heading' | 'quote' | 'checklist' | 'mediaAttachment';
|
|
210
223
|
type TextAlignment = 'left' | 'center' | 'right';
|
|
211
224
|
|
|
212
225
|
interface StyleRange {
|
|
@@ -229,6 +242,7 @@ type ToolbarOption =
|
|
|
229
242
|
| 'numbered'
|
|
230
243
|
| 'quote'
|
|
231
244
|
| 'checklist'
|
|
245
|
+
| 'mediaAttachment'
|
|
232
246
|
| 'link'
|
|
233
247
|
| 'undo'
|
|
234
248
|
| 'redo'
|
|
@@ -250,6 +264,41 @@ Use the `numberOfLines` prop along with `readOnly` to truncate content with an e
|
|
|
250
264
|
|
|
251
265
|
This works like React Native's `Text` component — content is truncated at the specified number of lines with a trailing ellipsis.
|
|
252
266
|
|
|
267
|
+
## Media Attachments
|
|
268
|
+
|
|
269
|
+
You can insert images into the editor using the `insertMediaAttachment` ref method. The toolbar also includes a `mediaAttachment` button that triggers the native media attachment flow.
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
import RichTextEditor, {
|
|
273
|
+
RichTextEditorRef,
|
|
274
|
+
MediaAttachment,
|
|
275
|
+
} from '@chaitrabhairappa/react-native-rich-text-editor';
|
|
276
|
+
|
|
277
|
+
const editorRef = useRef<RichTextEditorRef>(null);
|
|
278
|
+
|
|
279
|
+
// Insert an image programmatically
|
|
280
|
+
const insertImage = () => {
|
|
281
|
+
editorRef.current?.insertMediaAttachment({
|
|
282
|
+
kind: 'image',
|
|
283
|
+
uri: 'https://example.com/photo.png',
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Media attachment blocks are included in the content output:
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
{
|
|
292
|
+
type: 'mediaAttachment',
|
|
293
|
+
text: '',
|
|
294
|
+
styles: [],
|
|
295
|
+
mediaAttachment: {
|
|
296
|
+
kind: 'image',
|
|
297
|
+
uri: 'https://example.com/photo.png',
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
253
302
|
## Customizing Toolbar
|
|
254
303
|
|
|
255
304
|
```tsx
|
|
@@ -265,7 +314,14 @@ const toolbarOptions: ToolbarOption[] = ['bold', 'italic', 'underline', 'bullet'
|
|
|
265
314
|
|
|
266
315
|
## Changelog
|
|
267
316
|
|
|
268
|
-
###
|
|
317
|
+
### 3.0.0
|
|
318
|
+
|
|
319
|
+
- Add media attachment support — insert images into the editor (iOS & Android)
|
|
320
|
+
- New `insertMediaAttachment` ref method for programmatic image insertion
|
|
321
|
+
- New `mediaAttachment` toolbar option
|
|
322
|
+
- New `MediaAttachment` type and `mediaAttachment` block type
|
|
323
|
+
|
|
324
|
+
### 2.1.2
|
|
269
325
|
|
|
270
326
|
- Add `numberOfLines` prop to truncate text with ellipsis in readOnly mode (iOS & Android)
|
|
271
327
|
- Fix Android `onContentChange` to correctly extract text styles (bold, italic, underline, strikethrough, code, highlight)
|
|
@@ -34,6 +34,7 @@ class FloatingToolbar(context: Context) : LinearLayout(context) {
|
|
|
34
34
|
fun onNumberedListClick()
|
|
35
35
|
fun onQuoteClick()
|
|
36
36
|
fun onChecklistClick()
|
|
37
|
+
fun onMediaAttachmentClick()
|
|
37
38
|
fun onLinkClick()
|
|
38
39
|
fun onUndoClick()
|
|
39
40
|
fun onRedoClick()
|
|
@@ -63,6 +64,7 @@ class FloatingToolbar(context: Context) : LinearLayout(context) {
|
|
|
63
64
|
private var enabledOptions: List<String> = listOf(
|
|
64
65
|
"bold", "italic", "underline", "strikethrough", "code", "highlight",
|
|
65
66
|
"heading", "bullet", "numbered", "quote", "checklist",
|
|
67
|
+
"mediaAttachment",
|
|
66
68
|
"link", "undo", "redo", "clearFormatting",
|
|
67
69
|
"indent", "outdent",
|
|
68
70
|
"alignLeft", "alignCenter", "alignRight"
|
|
@@ -143,6 +145,7 @@ class FloatingToolbar(context: Context) : LinearLayout(context) {
|
|
|
143
145
|
enabledOptions = options ?: listOf(
|
|
144
146
|
"bold", "italic", "underline", "strikethrough", "code", "highlight",
|
|
145
147
|
"heading", "bullet", "numbered", "quote", "checklist",
|
|
148
|
+
"mediaAttachment",
|
|
146
149
|
"link", "undo", "redo", "clearFormatting",
|
|
147
150
|
"indent", "outdent",
|
|
148
151
|
"alignLeft", "alignCenter", "alignRight"
|
|
@@ -176,6 +179,7 @@ class FloatingToolbar(context: Context) : LinearLayout(context) {
|
|
|
176
179
|
"numbered" -> R.drawable.ic_format_list_numbered
|
|
177
180
|
"quote" -> R.drawable.ic_format_quote
|
|
178
181
|
"checklist" -> R.drawable.ic_format_checklist
|
|
182
|
+
"mediaAttachment" -> R.drawable.ic_format_media_attachment
|
|
179
183
|
"link" -> R.drawable.ic_format_link
|
|
180
184
|
"undo" -> R.drawable.ic_format_undo
|
|
181
185
|
"redo" -> R.drawable.ic_format_redo
|
|
@@ -227,6 +231,7 @@ class FloatingToolbar(context: Context) : LinearLayout(context) {
|
|
|
227
231
|
"numbered" -> button.setOnClickListener { listener?.onNumberedListClick() }
|
|
228
232
|
"quote" -> button.setOnClickListener { listener?.onQuoteClick() }
|
|
229
233
|
"checklist" -> button.setOnClickListener { listener?.onChecklistClick() }
|
|
234
|
+
"mediaAttachment" -> button.setOnClickListener { listener?.onMediaAttachmentClick() }
|
|
230
235
|
"link" -> button.setOnClickListener { listener?.onLinkClick() }
|
|
231
236
|
"undo" -> button.setOnClickListener { listener?.onUndoClick() }
|
|
232
237
|
"redo" -> button.setOnClickListener { listener?.onRedoClick() }
|
|
@@ -253,6 +258,7 @@ class FloatingToolbar(context: Context) : LinearLayout(context) {
|
|
|
253
258
|
numbered: Boolean = false,
|
|
254
259
|
quote: Boolean = false,
|
|
255
260
|
checklist: Boolean = false,
|
|
261
|
+
mediaAttachment: Boolean = false,
|
|
256
262
|
alignLeft: Boolean = true,
|
|
257
263
|
alignCenter: Boolean = false,
|
|
258
264
|
alignRight: Boolean = false
|
|
@@ -269,6 +275,7 @@ class FloatingToolbar(context: Context) : LinearLayout(context) {
|
|
|
269
275
|
"numbered" to numbered,
|
|
270
276
|
"quote" to quote,
|
|
271
277
|
"checklist" to checklist,
|
|
278
|
+
"mediaAttachment" to mediaAttachment,
|
|
272
279
|
"alignLeft" to alignLeft,
|
|
273
280
|
"alignCenter" to alignCenter,
|
|
274
281
|
"alignRight" to alignRight
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package com.richtext.editor
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.Paint
|
|
7
|
+
import android.graphics.RectF
|
|
8
|
+
import android.text.style.ReplacementSpan
|
|
9
|
+
import kotlin.math.ceil
|
|
10
|
+
|
|
11
|
+
data class MediaAttachmentData(
|
|
12
|
+
val kind: String,
|
|
13
|
+
val uri: String,
|
|
14
|
+
val widthDp: Int,
|
|
15
|
+
val heightDp: Int,
|
|
16
|
+
val alt: String
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class MediaAttachmentSpan(
|
|
20
|
+
private val data: MediaAttachmentData,
|
|
21
|
+
private val density: Float,
|
|
22
|
+
private val bitmap: Bitmap? = null,
|
|
23
|
+
private val lineSpacingMultiplier: Float = 1f
|
|
24
|
+
) : ReplacementSpan() {
|
|
25
|
+
|
|
26
|
+
fun toMediaAttachmentData(): MediaAttachmentData = data
|
|
27
|
+
|
|
28
|
+
private fun getWidthPx(): Int {
|
|
29
|
+
return bitmap?.width ?: (data.widthDp * density).toInt().coerceAtLeast(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private fun getHeightPx(): Int {
|
|
33
|
+
return bitmap?.height ?: (data.heightDp * density).toInt().coerceAtLeast(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override fun getSize(
|
|
37
|
+
paint: Paint,
|
|
38
|
+
text: CharSequence?,
|
|
39
|
+
start: Int,
|
|
40
|
+
end: Int,
|
|
41
|
+
fm: Paint.FontMetricsInt?
|
|
42
|
+
): Int {
|
|
43
|
+
val widthPx = getWidthPx()
|
|
44
|
+
if (fm != null) {
|
|
45
|
+
val imageHeightPx = getHeightPx()
|
|
46
|
+
val metricsHeightPx = if (lineSpacingMultiplier > 1f) {
|
|
47
|
+
ceil(imageHeightPx / lineSpacingMultiplier).toInt().coerceAtLeast(1)
|
|
48
|
+
} else {
|
|
49
|
+
imageHeightPx
|
|
50
|
+
}
|
|
51
|
+
fm.ascent = -metricsHeightPx
|
|
52
|
+
fm.descent = 0
|
|
53
|
+
fm.top = fm.ascent
|
|
54
|
+
fm.bottom = 0
|
|
55
|
+
}
|
|
56
|
+
return widthPx
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override fun draw(
|
|
60
|
+
canvas: Canvas,
|
|
61
|
+
text: CharSequence?,
|
|
62
|
+
start: Int,
|
|
63
|
+
end: Int,
|
|
64
|
+
x: Float,
|
|
65
|
+
top: Int,
|
|
66
|
+
y: Int,
|
|
67
|
+
bottom: Int,
|
|
68
|
+
paint: Paint
|
|
69
|
+
) {
|
|
70
|
+
val widthPx = getWidthPx().toFloat()
|
|
71
|
+
val heightPx = getHeightPx().toFloat()
|
|
72
|
+
val oldColor = paint.color
|
|
73
|
+
val oldStyle = paint.style
|
|
74
|
+
val lineHeight = (bottom - top).toFloat().coerceAtLeast(heightPx)
|
|
75
|
+
val rectTop = top + ((lineHeight - heightPx) / 2f)
|
|
76
|
+
|
|
77
|
+
if (bitmap != null) {
|
|
78
|
+
val destRect = RectF(x, rectTop, x + widthPx, rectTop + heightPx)
|
|
79
|
+
canvas.drawBitmap(bitmap, null, destRect, null)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
paint.color = oldColor
|
|
83
|
+
paint.style = oldStyle
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
package com.richtext.editor
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import android.text.Editable
|
|
8
|
+
import android.text.SpannableStringBuilder
|
|
9
|
+
import android.text.Spanned
|
|
10
|
+
import com.facebook.react.bridge.Arguments
|
|
11
|
+
import com.facebook.react.bridge.WritableMap
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
import java.net.URL
|
|
14
|
+
|
|
15
|
+
class MediaAttachmentSupport(
|
|
16
|
+
private val context: Context,
|
|
17
|
+
private val density: Float,
|
|
18
|
+
private val placeholderChar: Char,
|
|
19
|
+
private val getLineSpacingMultiplier: () -> Float,
|
|
20
|
+
private val getTargetWidthPx: () -> Int,
|
|
21
|
+
private val editableProvider: () -> Editable?,
|
|
22
|
+
private val runOnUiThread: ((() -> Unit) -> Unit),
|
|
23
|
+
private val onMediaSpansUpdated: () -> Unit
|
|
24
|
+
) {
|
|
25
|
+
|
|
26
|
+
private val mediaBitmapCache = mutableMapOf<String, Bitmap>()
|
|
27
|
+
private val loadingRemoteUris = mutableSetOf<String>()
|
|
28
|
+
|
|
29
|
+
// Block Data to MediaAttachmentData parsing
|
|
30
|
+
fun parseMediaData(block: Map<String, Any>): MediaAttachmentData? {
|
|
31
|
+
val blockType = block["type"] as? String ?: return null
|
|
32
|
+
if (blockType != "mediaAttachment") return null
|
|
33
|
+
|
|
34
|
+
val mediaInfo = block["mediaAttachment"] as? Map<*, *>
|
|
35
|
+
return MediaAttachmentData(
|
|
36
|
+
kind = mediaInfo?.get("kind") as? String ?: "image",
|
|
37
|
+
uri = mediaInfo?.get("uri") as? String ?: "",
|
|
38
|
+
widthDp = (mediaInfo?.get("width") as? Number)?.toInt() ?: 100,
|
|
39
|
+
heightDp = (mediaInfo?.get("height") as? Number)?.toInt() ?: 100,
|
|
40
|
+
alt = mediaInfo?.get("alt") as? String ?: ""
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun createMediaAttachmentSpan(mediaData: MediaAttachmentData): MediaAttachmentSpan {
|
|
45
|
+
val targetWidthPx = getTargetWidthPx().coerceAtLeast(1)
|
|
46
|
+
val loaded = loadBitmapForMedia(mediaData, targetWidthPx)
|
|
47
|
+
return if (loaded != null) {
|
|
48
|
+
val (bitmap, updatedData) = loaded
|
|
49
|
+
MediaAttachmentSpan(updatedData, density, bitmap, getLineSpacingMultiplier())
|
|
50
|
+
} else {
|
|
51
|
+
val fallbackData = normalizeMediaDimensions(mediaData, targetWidthPx)
|
|
52
|
+
MediaAttachmentSpan(fallbackData, density, null, getLineSpacingMultiplier())
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fun findMediaAttachmentSpan(spannable: Spanned, lineStart: Int, lineEnd: Int): MediaAttachmentSpan? {
|
|
57
|
+
val safeEnd = lineEnd.coerceAtLeast(lineStart + 1)
|
|
58
|
+
return spannable.getSpans(lineStart, safeEnd, MediaAttachmentSpan::class.java)
|
|
59
|
+
.firstOrNull { span ->
|
|
60
|
+
val spanStart = spannable.getSpanStart(span)
|
|
61
|
+
val spanEnd = spannable.getSpanEnd(span)
|
|
62
|
+
spanStart < lineEnd && spanEnd > lineStart
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fun isMediaLine(line: String): Boolean {
|
|
67
|
+
return line.replace(placeholderChar.toString(), "").trim().isEmpty()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fun createWritableMediaBlock(mediaData: MediaAttachmentData): WritableMap {
|
|
71
|
+
val block = Arguments.createMap()
|
|
72
|
+
block.putString("type", "mediaAttachment")
|
|
73
|
+
block.putString("text", "")
|
|
74
|
+
block.putArray("styles", Arguments.createArray())
|
|
75
|
+
|
|
76
|
+
val mediaMap = Arguments.createMap()
|
|
77
|
+
mediaMap.putString("kind", mediaData.kind)
|
|
78
|
+
mediaMap.putString("uri", mediaData.uri)
|
|
79
|
+
mediaMap.putInt("width", mediaData.widthDp)
|
|
80
|
+
mediaMap.putInt("height", mediaData.heightDp)
|
|
81
|
+
mediaMap.putString("alt", mediaData.alt)
|
|
82
|
+
block.putMap("mediaAttachment", mediaMap)
|
|
83
|
+
|
|
84
|
+
return block
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fun createJsonMediaBlock(mediaData: MediaAttachmentData): JSONObject {
|
|
88
|
+
val block = JSONObject()
|
|
89
|
+
block.put("type", "mediaAttachment")
|
|
90
|
+
block.put("text", "")
|
|
91
|
+
block.put("styles", org.json.JSONArray())
|
|
92
|
+
|
|
93
|
+
val mediaObj = JSONObject()
|
|
94
|
+
mediaObj.put("kind", mediaData.kind)
|
|
95
|
+
mediaObj.put("uri", mediaData.uri)
|
|
96
|
+
mediaObj.put("width", mediaData.widthDp)
|
|
97
|
+
mediaObj.put("height", mediaData.heightDp)
|
|
98
|
+
mediaObj.put("alt", mediaData.alt)
|
|
99
|
+
block.put("mediaAttachment", mediaObj)
|
|
100
|
+
|
|
101
|
+
return block
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fun appendMediaBlock(
|
|
105
|
+
spannable: SpannableStringBuilder,
|
|
106
|
+
currentOffset: Int,
|
|
107
|
+
mediaData: MediaAttachmentData,
|
|
108
|
+
appendTrailingNewline: Boolean
|
|
109
|
+
): Int {
|
|
110
|
+
var offset = currentOffset
|
|
111
|
+
val spanStart = offset
|
|
112
|
+
spannable.append(placeholderChar)
|
|
113
|
+
offset += 1
|
|
114
|
+
spannable.setSpan(
|
|
115
|
+
createMediaAttachmentSpan(mediaData),
|
|
116
|
+
spanStart,
|
|
117
|
+
spanStart + 1,
|
|
118
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if (appendTrailingNewline) {
|
|
122
|
+
spannable.append("\n")
|
|
123
|
+
offset += 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return offset
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fun insertMediaAttachmentBlock(editable: Editable, insertPos: Int, uri: String): Int {
|
|
130
|
+
var mutableInsertPos = insertPos.coerceIn(0, editable.length)
|
|
131
|
+
val mediaData = MediaAttachmentData(
|
|
132
|
+
kind = "image",
|
|
133
|
+
uri = uri,
|
|
134
|
+
widthDp = 100,
|
|
135
|
+
heightDp = 100,
|
|
136
|
+
alt = "Selected image"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if (mutableInsertPos > 0 && editable[mutableInsertPos - 1] != '\n') {
|
|
140
|
+
editable.insert(mutableInsertPos, "\n")
|
|
141
|
+
mutableInsertPos += 1
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
val spanStart = mutableInsertPos
|
|
145
|
+
editable.insert(spanStart, placeholderChar.toString())
|
|
146
|
+
editable.setSpan(
|
|
147
|
+
createMediaAttachmentSpan(mediaData),
|
|
148
|
+
spanStart,
|
|
149
|
+
spanStart + 1,
|
|
150
|
+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
var nextPos = spanStart + 1
|
|
154
|
+
val hasFollowingText = nextPos < editable.length
|
|
155
|
+
if (hasFollowingText && editable[nextPos] != '\n') {
|
|
156
|
+
editable.insert(nextPos, "\n")
|
|
157
|
+
nextPos += 1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return nextPos.coerceAtMost(editable.length)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private fun normalizeMediaDimensions(mediaData: MediaAttachmentData, targetWidthPx: Int): MediaAttachmentData {
|
|
164
|
+
val targetWidthDp = (targetWidthPx / density).toInt().coerceAtLeast(1)
|
|
165
|
+
val fallbackHeightDp = if (mediaData.widthDp > 0 && mediaData.heightDp > 0) {
|
|
166
|
+
((mediaData.heightDp.toFloat() / mediaData.widthDp.toFloat()) * targetWidthDp).toInt().coerceAtLeast(1)
|
|
167
|
+
} else {
|
|
168
|
+
targetWidthDp
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return mediaData.copy(
|
|
172
|
+
widthDp = targetWidthDp,
|
|
173
|
+
heightDp = fallbackHeightDp
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun buildScaledBitmapWithAspect(bitmap: Bitmap, targetWidthPx: Int): Pair<Bitmap, Int> {
|
|
178
|
+
val safeTargetWidthPx = targetWidthPx.coerceAtLeast(1)
|
|
179
|
+
val aspectRatio = if (bitmap.width > 0) {
|
|
180
|
+
bitmap.height.toFloat() / bitmap.width.toFloat()
|
|
181
|
+
} else {
|
|
182
|
+
1f
|
|
183
|
+
}
|
|
184
|
+
val targetHeightPx = (safeTargetWidthPx * aspectRatio).toInt().coerceAtLeast(1)
|
|
185
|
+
|
|
186
|
+
val scaled = if (bitmap.width == safeTargetWidthPx && bitmap.height == targetHeightPx) {
|
|
187
|
+
bitmap
|
|
188
|
+
} else {
|
|
189
|
+
Bitmap.createScaledBitmap(bitmap, safeTargetWidthPx, targetHeightPx, true)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return Pair(scaled, targetHeightPx)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fun loadBitmapForMedia(mediaData: MediaAttachmentData, targetWidthPx: Int): Pair<Bitmap, MediaAttachmentData>? {
|
|
196
|
+
if (mediaData.uri.isBlank() || mediaData.uri == "red-box-placeholder") return null
|
|
197
|
+
|
|
198
|
+
mediaBitmapCache[mediaData.uri]?.let { cached ->
|
|
199
|
+
val (scaledBitmap, targetHeightPx) = buildScaledBitmapWithAspect(cached, targetWidthPx)
|
|
200
|
+
val updatedData = mediaData.copy(
|
|
201
|
+
widthDp = (targetWidthPx / density).toInt().coerceAtLeast(1),
|
|
202
|
+
heightDp = (targetHeightPx / density).toInt().coerceAtLeast(1)
|
|
203
|
+
)
|
|
204
|
+
return Pair(scaledBitmap, updatedData)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return try {
|
|
208
|
+
val uri = Uri.parse(mediaData.uri)
|
|
209
|
+
|
|
210
|
+
when (uri.scheme?.lowercase()) {
|
|
211
|
+
"http", "https" -> {
|
|
212
|
+
loadRemoteBitmapAsync(mediaData, targetWidthPx)
|
|
213
|
+
null
|
|
214
|
+
}
|
|
215
|
+
else -> {
|
|
216
|
+
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
|
217
|
+
BitmapFactory.decodeStream(inputStream)
|
|
218
|
+
}?.let { original ->
|
|
219
|
+
mediaBitmapCache[mediaData.uri] = original
|
|
220
|
+
val (scaledBitmap, targetHeightPx) = buildScaledBitmapWithAspect(original, targetWidthPx)
|
|
221
|
+
val updatedData = mediaData.copy(
|
|
222
|
+
widthDp = (targetWidthPx / density).toInt().coerceAtLeast(1),
|
|
223
|
+
heightDp = (targetHeightPx / density).toInt().coerceAtLeast(1)
|
|
224
|
+
)
|
|
225
|
+
Pair(scaledBitmap, updatedData)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch (_: Exception) {
|
|
230
|
+
null
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fun loadRemoteBitmapAsync(mediaData: MediaAttachmentData, targetWidthPx: Int) {
|
|
235
|
+
val uriString = mediaData.uri
|
|
236
|
+
synchronized(loadingRemoteUris) {
|
|
237
|
+
if (loadingRemoteUris.contains(uriString)) return
|
|
238
|
+
loadingRemoteUris.add(uriString)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
Thread {
|
|
242
|
+
try {
|
|
243
|
+
val loaded = URL(uriString).openStream().use { inputStream ->
|
|
244
|
+
BitmapFactory.decodeStream(inputStream)
|
|
245
|
+
} ?: return@Thread
|
|
246
|
+
|
|
247
|
+
mediaBitmapCache[uriString] = loaded
|
|
248
|
+
val (scaledBitmap, targetHeightPx) = buildScaledBitmapWithAspect(loaded, targetWidthPx)
|
|
249
|
+
val updatedData = mediaData.copy(
|
|
250
|
+
widthDp = (targetWidthPx / density).toInt().coerceAtLeast(1),
|
|
251
|
+
heightDp = (targetHeightPx / density).toInt().coerceAtLeast(1)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
runOnUiThread {
|
|
255
|
+
synchronized(loadingRemoteUris) {
|
|
256
|
+
loadingRemoteUris.remove(uriString)
|
|
257
|
+
}
|
|
258
|
+
refreshMediaSpansForUri(uriString, scaledBitmap, updatedData)
|
|
259
|
+
}
|
|
260
|
+
} catch (_: Exception) {
|
|
261
|
+
runOnUiThread {
|
|
262
|
+
synchronized(loadingRemoteUris) {
|
|
263
|
+
loadingRemoteUris.remove(uriString)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}.start()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private fun refreshMediaSpansForUri(uriString: String, bitmap: Bitmap, updatedData: MediaAttachmentData) {
|
|
271
|
+
val editable = editableProvider() ?: return
|
|
272
|
+
val spans = editable.getSpans(0, editable.length, MediaAttachmentSpan::class.java)
|
|
273
|
+
var updated = false
|
|
274
|
+
|
|
275
|
+
spans.forEach { span ->
|
|
276
|
+
val data = span.toMediaAttachmentData()
|
|
277
|
+
if (data.uri == uriString) {
|
|
278
|
+
val start = editable.getSpanStart(span)
|
|
279
|
+
val end = editable.getSpanEnd(span)
|
|
280
|
+
val flags = editable.getSpanFlags(span)
|
|
281
|
+
editable.removeSpan(span)
|
|
282
|
+
editable.setSpan(
|
|
283
|
+
MediaAttachmentSpan(updatedData, density, bitmap, getLineSpacingMultiplier()),
|
|
284
|
+
start,
|
|
285
|
+
end,
|
|
286
|
+
flags
|
|
287
|
+
)
|
|
288
|
+
updated = true
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (updated) {
|
|
293
|
+
onMediaSpansUpdated()
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|