diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index 086b5d8e9..8354ceb68 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -527,11 +527,11 @@ class EnrichedTextInputView : AppCompatEditText { parametrizedStyles?.setLinkSpan(start, end, text, url) } - fun addImage(src: String) { + fun addImage(src: String, width: Float, height: Float) { val isValid = verifyStyle(EnrichedSpans.IMAGE) if (!isValid) return - parametrizedStyles?.setImageSpan(src) + parametrizedStyles?.setImageSpan(src, width, height) layoutManager.invalidateLayout() } diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt index 286a19b7b..439b4e87c 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt @@ -255,8 +255,8 @@ class EnrichedTextInputViewManager : SimpleViewManager(), view?.addLink(start, end, text, url) } - override fun addImage(view: EnrichedTextInputView?, src: String) { - view?.addImage(src) + override fun addImage(view: EnrichedTextInputView?, src: String, width: Float, height: Float) { + view?.addImage(src, width, height) } override fun startMention(view: EnrichedTextInputView?, indicator: String) { diff --git a/android/src/main/java/com/swmansion/enriched/spans/EnrichedImageSpan.kt b/android/src/main/java/com/swmansion/enriched/spans/EnrichedImageSpan.kt index 708050d62..8bf466310 100644 --- a/android/src/main/java/com/swmansion/enriched/spans/EnrichedImageSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedImageSpan.kt @@ -1,6 +1,7 @@ package com.swmansion.enriched.spans import android.content.Context +import android.content.res.Resources import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Drawable @@ -8,17 +9,19 @@ import android.net.Uri import android.text.style.ImageSpan import androidx.core.graphics.withSave import com.swmansion.enriched.spans.interfaces.EnrichedInlineSpan -import com.swmansion.enriched.styles.HtmlStyle class EnrichedImageSpan : ImageSpan, EnrichedInlineSpan { - private var htmlStyle: HtmlStyle? = null + private var width: Int = 0 + private var height: Int = 0 - constructor(context: Context, uri: Uri, htmlStyle: HtmlStyle, ) : super(context, uri, ALIGN_BASELINE) { - this.htmlStyle = htmlStyle + constructor(context: Context, uri: Uri, width: Int, height: Int) : super(context, uri, ALIGN_BASELINE) { + this.width = width + this.height = height } - constructor(drawable: Drawable, source: String, htmlStyle: HtmlStyle) : super(drawable, source, ALIGN_BASELINE) { - this.htmlStyle = htmlStyle + constructor(drawable: Drawable, source: String, width: Int, height: Int) : super(drawable, source, ALIGN_BASELINE) { + this.width = width + this.height = height } override fun draw( @@ -35,7 +38,17 @@ class EnrichedImageSpan : ImageSpan, EnrichedInlineSpan { override fun getDrawable(): Drawable { val drawable = super.getDrawable() - drawable.setBounds(0, 0, htmlStyle!!.imgWidth, htmlStyle!!.imgHeight) + val scale = Resources.getSystem().displayMetrics.density + + drawable.setBounds(0, 0, (width * scale).toInt() , (height * scale).toInt()) return drawable } + + fun getWidth(): Int { + return width + } + + fun getHeight(): Int { + return height + } } diff --git a/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt index 8a4b9088f..454c869cd 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt @@ -43,9 +43,6 @@ class HtmlStyle { var ulBulletSize: Int = 8 var ulBulletColor: Int = Color.BLACK - var imgWidth: Int = 200 - var imgHeight: Int = 200 - var aColor: Int = Color.BLACK var aUnderline: Boolean = true @@ -100,10 +97,6 @@ class HtmlStyle { ulMarginLeft = parseFloat(ulStyle, "marginLeft").toInt() ulBulletSize = parseFloat(ulStyle, "bulletSize").toInt() - val imgStyle = style.getMap("img") - imgWidth = parseFloat(imgStyle, "width").toInt() - imgHeight = parseFloat(imgStyle, "height").toInt() - val aStyle = style.getMap("a") aColor = parseColor(aStyle, "color") aUnderline = parseIsUnderline(aStyle) @@ -124,8 +117,8 @@ class HtmlStyle { private fun parseFloat(map: ReadableMap?, key: String): Float { val safeMap = ensureValueIsSet(map, key) - val fontSize = safeMap.getDouble(key) - return ceil(PixelUtil.toPixelFromSP(fontSize)) + val value = safeMap.getDouble(key) + return ceil(PixelUtil.toPixelFromSP(value)) } private fun parseColorWithOpacity(map: ReadableMap?, key: String, opacity: Int): Int { diff --git a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt index 9b8665606..5b80e9e96 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt @@ -170,26 +170,28 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } } - fun setImageSpan(src: String) { + fun setImageSpan(src: String, width: Float, height: Float) { if (view.selection == null) return val spannable = view.text as SpannableStringBuilder - var (start, end) = view.selection.getInlineSelection() - val spans = spannable.getSpans(start, end, EnrichedImageSpan::class.java) + val (start, originalEnd) = view.selection.getInlineSelection() - for (s in spans) { - spannable.removeSpan(s) - } - - if (start == end) { + if (start == originalEnd) { spannable.insert(start, "\uFFFC") - end++ + } else { + val spans = spannable.getSpans(start, originalEnd, EnrichedImageSpan::class.java) + for (s in spans) { + spannable.removeSpan(s) + } + + spannable.replace(start, originalEnd, "\uFFFC") } + val (imageStart, imageEnd) = spannable.getSafeSpanBoundaries(start, start + 1) val uri = Uri.fromFile(File(src)) - val span = EnrichedImageSpan(view.context, uri, view.htmlStyle) - val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) - spannable.setSpan(span, safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + + val span = EnrichedImageSpan(view.context, uri, width.toInt(), height.toInt()) + spannable.setSpan(span, imageStart, imageEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } fun startMention(indicator: String) { diff --git a/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java index 82fe9114a..b89e9a8aa 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java @@ -1,5 +1,9 @@ package com.swmansion.enriched.utils; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.text.Editable; import android.text.Layout; @@ -9,6 +13,7 @@ import android.text.TextUtils; import android.text.style.AlignmentSpan; import android.text.style.ParagraphStyle; +import android.util.Log; import com.swmansion.enriched.spans.EnrichedBlockQuoteSpan; import com.swmansion.enriched.spans.EnrichedBoldSpan; @@ -275,7 +280,16 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, if (style[j] instanceof EnrichedImageSpan) { out.append(""); + out.append("\""); + + out.append(" width=\""); + out.append(((EnrichedImageSpan) style[j]).getWidth()); + out.append("\""); + + out.append(" height=\""); + out.append(((EnrichedImageSpan) style[j]).getHeight()); + + out.append("\"/>"); // Don't output the placeholder character underlying the image. i = next; } @@ -454,7 +468,7 @@ private void handleStartTag(String tag, Attributes attributes) { } else if (tag.equalsIgnoreCase("h3")) { startHeading(mSpannableStringBuilder, 3); } else if (tag.equalsIgnoreCase("img")) { - startImg(mSpannableStringBuilder, attributes, mImageGetter, mStyle); + startImg(mSpannableStringBuilder, attributes, mImageGetter); } else if (tag.equalsIgnoreCase("code")) { start(mSpannableStringBuilder, new Code()); } else if (tag.equalsIgnoreCase("mention")) { @@ -679,20 +693,51 @@ private static void end(Editable text, Class kind, Object repl) { } } - private static void startImg(Editable text, Attributes attributes, EnrichedParser.ImageGetter img, HtmlStyle style) { + private static void startImg(Editable text, Attributes attributes, EnrichedParser.ImageGetter img) { String src = attributes.getValue("", "src"); + String width = attributes.getValue("", "width"); + String height = attributes.getValue("", "height"); + Drawable d = null; if (img != null) { d = img.getDrawable(src); } + if (d == null) { + d = HtmlToSpannedConverter.prepareDrawableForImage(src); + } + if (d == null) { return; } int len = text.length(); text.append(""); - text.setSpan(new EnrichedImageSpan(d, src, style), len, text.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(new EnrichedImageSpan(d, src, Integer.parseInt(width), Integer.parseInt(height)), len, text.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + private static BitmapDrawable prepareDrawableForImage(String src) { + String cleanPath = src; + if (cleanPath.startsWith("file://")) { + cleanPath = cleanPath.substring(7); + } + + BitmapDrawable drawable = null; + + try { + Bitmap bitmap = BitmapFactory.decodeFile(cleanPath); + if (bitmap != null) { + drawable = new BitmapDrawable(Resources.getSystem(), bitmap); + // set bounds so it knows how big it is naturally, + // though EnrichedImageSpan will override this with the HTML width/height later. + drawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight()); + } + } catch (Exception e) { + // Failed to load file + Log.e("EnrichedParser", "Failed to load image from path: " + cleanPath, e); + } + + return drawable; } private static void startA(Editable text, Attributes attributes) { diff --git a/example/src/App.tsx b/example/src/App.tsx index 3c29531fe..737bc66b9 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -27,6 +27,7 @@ import { type MentionItem, MentionPopup } from './components/MentionPopup'; import { useUserMention } from './useUserMention'; import { useChannelMention } from './useChannelMention'; import { HtmlSection } from './components/HtmlSection'; +import { ImageModal } from './components/ImageModal'; type StylesState = OnChangeStateEvent; @@ -75,6 +76,7 @@ export default function App() { const [isChannelPopupOpen, setIsChannelPopupOpen] = useState(false); const [isUserPopupOpen, setIsUserPopupOpen] = useState(false); const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); + const [isImageModalOpen, setIsImageModalOpen] = useState(false); const [isValueModalOpen, setIsValueModalOpen] = useState(false); const [currentHtml, setCurrentHtml] = useState(''); @@ -125,6 +127,14 @@ export default function App() { setIsLinkModalOpen(false); }; + const openImageModal = () => { + setIsImageModalOpen(true); + }; + + const closeImageModal = () => { + setIsImageModalOpen(false); + }; + const openUserMentionPopup = () => { setIsUserPopupOpen(true); }; @@ -197,7 +207,7 @@ export default function App() { closeValueModal(); }; - const selectImage = async () => { + const selectImage = async (width: number, height: number) => { const response = await launchImageLibrary({ mediaType: 'photo', selectionLimit: 1, @@ -208,9 +218,11 @@ export default function App() { ? response.assets?.[0]?.originalPath : response.assets?.[0]?.uri; - if (!imageUri) return; + if (imageUri) { + ref.current?.setImage(imageUri, width, height); + } - ref.current?.setImage(imageUri); + closeImageModal(); }; const handleChangeMention = ({ indicator, text }: OnChangeMentionEvent) => { @@ -294,7 +306,7 @@ export default function App() { stylesState={stylesState} editorRef={ref} onOpenLinkModal={openLinkModal} - onSelectImage={selectImage} + onSelectImage={openImageModal} /> @@ -318,6 +330,11 @@ export default function App() { onSubmit={submitLink} onClose={closeLinkModal} /> + void; + onSubmit: (width: number, height: number) => void; +} + +const DEFAULT_IMAGE_WIDTH = 80; +const DEFAULT_IMAGE_HEIGHT = 80; + +export const ImageModal: FC = ({ + isOpen, + onClose, + onSubmit, +}) => { + const [width, setWidth] = useState(''); + const [height, setHeight] = useState(''); + + const handleSave = () => { + const parsedWidth = parseFloat(width); + const parsedHeight = parseFloat(height); + const finalWidth = isNaN(parsedWidth) ? DEFAULT_IMAGE_WIDTH : parsedWidth; + const finalHeight = isNaN(parsedHeight) + ? DEFAULT_IMAGE_HEIGHT + : parsedHeight; + + onSubmit(finalWidth, finalHeight); + }; + + return ( + + + + + + + + + + + +