TextViewSpan and TextLayoutSpan
This commit is contained in:
		
							parent
							
								
									3006f8d486
								
							
						
					
					
						commit
						b497f872e5
					
				| @ -1,6 +1,12 @@ | ||||
| # Changelog | ||||
| 
 | ||||
| # $nap; | ||||
| * `TextViewSpan` to obtain `TextView` in which markdown is displayed (applied by `CorePlugin`) | ||||
| * `TextLayoutSpan` to obtain `Layout` in which markdown is displayed (applied by `TablePlugin`, more specifically `TableRowSpan` to propagate layout in which cell content is displayed) | ||||
| * `HtmlEmptyTagReplacement` now is configurable by `HtmlPlugin`, `iframe` handling ([#235]) | ||||
| * `AsyncDrawableLoader` now uses `TextView` width without padding instead of width of canvas | ||||
| 
 | ||||
| [#235]: https://github.com/noties/Markwon/issues/235 | ||||
| 
 | ||||
| # 4.3.1 | ||||
| * Fix DexGuard optimization issue ([#216])<br>Thanks [@francescocervone] | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| package io.noties.markwon.core; | ||||
| 
 | ||||
| import android.text.Spannable; | ||||
| import android.text.Spanned; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.widget.TextView; | ||||
| @ -48,6 +49,7 @@ import io.noties.markwon.core.factory.ListItemSpanFactory; | ||||
| import io.noties.markwon.core.factory.StrongEmphasisSpanFactory; | ||||
| import io.noties.markwon.core.factory.ThematicBreakSpanFactory; | ||||
| import io.noties.markwon.core.spans.OrderedListItemSpan; | ||||
| import io.noties.markwon.core.spans.TextViewSpan; | ||||
| import io.noties.markwon.image.ImageProps; | ||||
| 
 | ||||
| /** | ||||
| @ -150,6 +152,13 @@ public class CorePlugin extends AbstractMarkwonPlugin { | ||||
|     @Override | ||||
|     public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { | ||||
|         OrderedListItemSpan.measure(textView, markdown); | ||||
| 
 | ||||
|         // @since $nap; | ||||
|         // we do not break API compatibility, instead we introduce the `instance of` check | ||||
|         if (markdown instanceof Spannable) { | ||||
|             final Spannable spannable = (Spannable) markdown; | ||||
|             TextViewSpan.applyTo(spannable, textView); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|  | ||||
| @ -0,0 +1,70 @@ | ||||
| package io.noties.markwon.core.spans; | ||||
| 
 | ||||
| import android.text.Layout; | ||||
| import android.text.Spannable; | ||||
| import android.text.Spanned; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.lang.ref.WeakReference; | ||||
| 
 | ||||
| /** | ||||
|  * @since $nap; | ||||
|  */ | ||||
| public class TextLayoutSpan { | ||||
| 
 | ||||
|     /** | ||||
|      * @see #applyTo(Spannable, Layout) | ||||
|      */ | ||||
|     @Nullable | ||||
|     public static Layout layoutOf(@NonNull CharSequence cs) { | ||||
|         if (cs instanceof Spanned) { | ||||
|             return layoutOf((Spanned) cs); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static Layout layoutOf(@NonNull Spanned spanned) { | ||||
|         final TextLayoutSpan[] spans = spanned.getSpans( | ||||
|                 0, | ||||
|                 spanned.length(), | ||||
|                 TextLayoutSpan.class | ||||
|         ); | ||||
|         return spans != null && spans.length > 0 | ||||
|                 ? spans[0].layout() | ||||
|                 : null; | ||||
|     } | ||||
| 
 | ||||
|     public static void applyTo(@NonNull Spannable spannable, @NonNull Layout layout) { | ||||
| 
 | ||||
|         // remove all current ones (only one should be present) | ||||
|         final TextLayoutSpan[] spans = spannable.getSpans(0, spannable.length(), TextLayoutSpan.class); | ||||
|         if (spans != null) { | ||||
|             for (TextLayoutSpan span : spans) { | ||||
|                 spannable.removeSpan(span); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         final TextLayoutSpan span = new TextLayoutSpan(layout); | ||||
|         spannable.setSpan( | ||||
|                 span, | ||||
|                 0, | ||||
|                 spannable.length(), | ||||
|                 Spanned.SPAN_INCLUSIVE_INCLUSIVE | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     private final WeakReference<Layout> reference; | ||||
| 
 | ||||
|     @SuppressWarnings("WeakerAccess") | ||||
|     TextLayoutSpan(@NonNull Layout layout) { | ||||
|         this.reference = new WeakReference<>(layout); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public Layout layout() { | ||||
|         return reference.get(); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,64 @@ | ||||
| package io.noties.markwon.core.spans; | ||||
| 
 | ||||
| import android.text.Spannable; | ||||
| import android.text.Spanned; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.lang.ref.WeakReference; | ||||
| 
 | ||||
| /** | ||||
|  * A special span that allows to obtain {@code TextView} in which spans are displayed | ||||
|  * | ||||
|  * @since $nap; | ||||
|  */ | ||||
| public class TextViewSpan { | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static TextView textViewOf(@NonNull CharSequence cs) { | ||||
|         if (cs instanceof Spanned) { | ||||
|             return textViewOf((Spanned) cs); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static TextView textViewOf(@NonNull Spanned spanned) { | ||||
|         final TextViewSpan[] spans = spanned.getSpans(0, spanned.length(), TextViewSpan.class); | ||||
|         return spans != null && spans.length > 0 | ||||
|                 ? spans[0].textView() | ||||
|                 : null; | ||||
|     } | ||||
| 
 | ||||
|     public static void applyTo(@NonNull Spannable spannable, @NonNull TextView textView) { | ||||
| 
 | ||||
|         final TextViewSpan[] spans = spannable.getSpans(0, spannable.length(), TextViewSpan.class); | ||||
|         if (spans != null) { | ||||
|             for (TextViewSpan span : spans) { | ||||
|                 spannable.removeSpan(span); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         final TextViewSpan span = new TextViewSpan(textView); | ||||
|         // `SPAN_INCLUSIVE_INCLUSIVE` to persist in case of possible text change (deletion, etc) | ||||
|         spannable.setSpan( | ||||
|                 span, | ||||
|                 0, | ||||
|                 spannable.length(), | ||||
|                 Spanned.SPAN_INCLUSIVE_INCLUSIVE | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     private final WeakReference<TextView> reference; | ||||
| 
 | ||||
|     public TextViewSpan(@NonNull TextView textView) { | ||||
|         this.reference = new WeakReference<>(textView); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public TextView textView() { | ||||
|         return reference.get(); | ||||
|     } | ||||
| } | ||||
| @ -14,6 +14,7 @@ import java.lang.annotation.Retention; | ||||
| import java.lang.annotation.RetentionPolicy; | ||||
| 
 | ||||
| import io.noties.markwon.core.MarkwonTheme; | ||||
| import io.noties.markwon.utils.SpanUtils; | ||||
| 
 | ||||
| @SuppressWarnings("WeakerAccess") | ||||
| public class AsyncDrawableSpan extends ReplacementSpan { | ||||
| @ -99,7 +100,11 @@ public class AsyncDrawableSpan extends ReplacementSpan { | ||||
|             int bottom, | ||||
|             @NonNull Paint paint) { | ||||
| 
 | ||||
|         drawable.initWithKnownDimensions(canvas.getWidth(), paint.getTextSize()); | ||||
|         // @since $nap; use SpanUtils instead of `canvas.getWidth` | ||||
|         drawable.initWithKnownDimensions( | ||||
|                 SpanUtils.width(canvas, text), | ||||
|                 paint.getTextSize() | ||||
|         ); | ||||
| 
 | ||||
|         final AsyncDrawable drawable = this.drawable; | ||||
| 
 | ||||
|  | ||||
| @ -18,6 +18,7 @@ public class Dip { | ||||
| 
 | ||||
|     private final float density; | ||||
| 
 | ||||
|     @SuppressWarnings("WeakerAccess") | ||||
|     public Dip(float density) { | ||||
|         this.density = density; | ||||
|     } | ||||
|  | ||||
| @ -12,6 +12,7 @@ import java.lang.reflect.Method; | ||||
| import java.lang.reflect.Proxy; | ||||
| 
 | ||||
| // utility class to print parsed Nodes hierarchy | ||||
| @SuppressWarnings({"unused", "WeakerAccess"}) | ||||
| public abstract class DumpNodes { | ||||
| 
 | ||||
|     public interface NodeProcessor { | ||||
|  | ||||
| @ -0,0 +1,72 @@ | ||||
| package io.noties.markwon.utils; | ||||
| 
 | ||||
| import android.os.Build; | ||||
| import android.text.Layout; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| /** | ||||
|  * @since $nap; | ||||
|  */ | ||||
| public abstract class LayoutUtils { | ||||
| 
 | ||||
|     private static final float DEFAULT_EXTRA = 0F; | ||||
|     private static final float DEFAULT_MULTIPLIER = 1F; | ||||
| 
 | ||||
|     public static int getLineBottomWithoutPaddingAndSpacing( | ||||
|             @NonNull Layout layout, | ||||
|             int line | ||||
|     ) { | ||||
| 
 | ||||
|         final int bottom = layout.getLineBottom(line); | ||||
|         final boolean lastLineSpacingNotAdded = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; | ||||
|         final boolean isSpanLastLine = line == (layout.getLineCount() - 1); | ||||
| 
 | ||||
|         final int lineBottom; | ||||
|         final float lineSpacingExtra = layout.getSpacingAdd(); | ||||
|         final float lineSpacingMultiplier = layout.getSpacingMultiplier(); | ||||
| 
 | ||||
|         // simplified check | ||||
|         final boolean hasLineSpacing = lineSpacingExtra != DEFAULT_EXTRA | ||||
|                 || lineSpacingMultiplier != DEFAULT_MULTIPLIER; | ||||
| 
 | ||||
|         if (!hasLineSpacing | ||||
|                 || (isSpanLastLine && lastLineSpacingNotAdded)) { | ||||
|             lineBottom = bottom; | ||||
|         } else { | ||||
|             final float extra; | ||||
|             if (Float.compare(DEFAULT_MULTIPLIER, lineSpacingMultiplier) != 0) { | ||||
|                 final int lineHeight = getLineHeight(layout, line); | ||||
|                 extra = lineHeight - | ||||
|                         ((lineHeight - lineSpacingExtra) / lineSpacingMultiplier); | ||||
|             } else { | ||||
|                 extra = lineSpacingExtra; | ||||
|             } | ||||
|             lineBottom = (int) (bottom - extra + .5F); | ||||
|         } | ||||
| 
 | ||||
|         // check if it is the last line that span is occupying **and** that this line is the last | ||||
|         //  one in TextView | ||||
|         if (isSpanLastLine | ||||
|                 && (line == layout.getLineCount() - 1)) { | ||||
|             return lineBottom - layout.getBottomPadding(); | ||||
|         } | ||||
| 
 | ||||
|         return lineBottom; | ||||
|     } | ||||
| 
 | ||||
|     public static int getLineTopWithoutPadding(@NonNull Layout layout, int line) { | ||||
|         final int top = layout.getLineTop(line); | ||||
|         if (line == 0) { | ||||
|             return top - layout.getTopPadding(); | ||||
|         } | ||||
|         return top; | ||||
|     } | ||||
| 
 | ||||
|     public static int getLineHeight(@NonNull Layout layout, int line) { | ||||
|         return layout.getLineTop(line + 1) - layout.getLineTop(line); | ||||
|     } | ||||
| 
 | ||||
|     private LayoutUtils() { | ||||
|     } | ||||
| } | ||||
| @ -4,7 +4,6 @@ import android.text.Spanned; | ||||
| 
 | ||||
| public abstract class LeadingMarginUtils { | ||||
| 
 | ||||
|     @SuppressWarnings("BooleanMethodIsAlwaysInverted") | ||||
|     public static boolean selfStart(int start, CharSequence text, Object span) { | ||||
|         return text instanceof Spanned && ((Spanned) text).getSpanStart(span) == start; | ||||
|     } | ||||
|  | ||||
| @ -0,0 +1,42 @@ | ||||
| package io.noties.markwon.utils; | ||||
| 
 | ||||
| import android.graphics.Canvas; | ||||
| import android.text.Layout; | ||||
| import android.text.Spanned; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.TextLayoutSpan; | ||||
| import io.noties.markwon.core.spans.TextViewSpan; | ||||
| 
 | ||||
| /** | ||||
|  * @since $nap; | ||||
|  */ | ||||
| public abstract class SpanUtils { | ||||
| 
 | ||||
|     public static int width(@NonNull Canvas canvas, @NonNull CharSequence cs) { | ||||
|         // Layout | ||||
|         // TextView | ||||
|         // canvas | ||||
| 
 | ||||
|         if (cs instanceof Spanned) { | ||||
|             final Spanned spanned = (Spanned) cs; | ||||
| 
 | ||||
|             // if we are displayed with layout information -> use it | ||||
|             final Layout layout = TextLayoutSpan.layoutOf(spanned); | ||||
|             if (layout != null) { | ||||
|                 return layout.getWidth(); | ||||
|             } | ||||
| 
 | ||||
|             // if we have TextView -> obtain width from it (exclude padding) | ||||
|             final TextView textView = TextViewSpan.textViewOf(spanned); | ||||
|             if (textView != null) { | ||||
|                 return textView.getWidth() - textView.getPaddingLeft() - textView.getPaddingRight(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // else just use canvas width | ||||
|         return canvas.getWidth(); | ||||
|     } | ||||
| } | ||||
| @ -5,6 +5,8 @@ import android.graphics.Canvas; | ||||
| import android.graphics.Paint; | ||||
| import android.graphics.Rect; | ||||
| import android.text.Layout; | ||||
| import android.text.Spannable; | ||||
| import android.text.SpannableString; | ||||
| import android.text.Spanned; | ||||
| import android.text.StaticLayout; | ||||
| import android.text.TextPaint; | ||||
| @ -20,7 +22,9 @@ import java.lang.annotation.RetentionPolicy; | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.TextLayoutSpan; | ||||
| import io.noties.markwon.utils.LeadingMarginUtils; | ||||
| import io.noties.markwon.utils.SpanUtils; | ||||
| 
 | ||||
| public class TableRowSpan extends ReplacementSpan { | ||||
| 
 | ||||
| @ -144,7 +148,7 @@ public class TableRowSpan extends ReplacementSpan { | ||||
|             int bottom, | ||||
|             @NonNull Paint p) { | ||||
| 
 | ||||
|         if (recreateLayouts(canvas.getWidth())) { | ||||
|         if (recreateLayouts(SpanUtils.width(canvas, text))) { | ||||
|             width = canvas.getWidth(); | ||||
|             // @since 4.3.1 it's important to cast to TextPaint in order to display links, etc | ||||
|             if (p instanceof TextPaint) { | ||||
| @ -295,17 +299,31 @@ public class TableRowSpan extends ReplacementSpan { | ||||
|         this.layouts.clear(); | ||||
|         Cell cell; | ||||
|         StaticLayout layout; | ||||
|         Spannable spannable; | ||||
| 
 | ||||
|         for (int i = 0, size = cells.size(); i < size; i++) { | ||||
| 
 | ||||
|             cell = cells.get(i); | ||||
| 
 | ||||
|             if (cell.text instanceof Spannable) { | ||||
|                 spannable = (Spannable) cell.text; | ||||
|             } else { | ||||
|                 spannable = new SpannableString(cell.text); | ||||
|             } | ||||
| 
 | ||||
|             layout = new StaticLayout( | ||||
|                     cell.text, | ||||
|                     spannable, | ||||
|                     textPaint, | ||||
|                     w, | ||||
|                     alignment(cell.alignment), | ||||
|                     1.F, | ||||
|                     .0F, | ||||
|                     1.0F, | ||||
|                     0.0F, | ||||
|                     false | ||||
|             ); | ||||
| 
 | ||||
|             // @since $nap; | ||||
|             TextLayoutSpan.applyTo(spannable, layout); | ||||
| 
 | ||||
|             layouts.add(layout); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -21,6 +21,7 @@ public class HtmlEmptyTagReplacement { | ||||
|     } | ||||
| 
 | ||||
|     private static final String IMG_REPLACEMENT = "\uFFFC"; | ||||
|     private static final String IFRAME_REPLACEMENT = "\u00a0"; // non-breakable space | ||||
| 
 | ||||
|     /** | ||||
|      * @return replacement for supplied startTag or null if no replacement should occur (which will | ||||
| @ -44,6 +45,9 @@ public class HtmlEmptyTagReplacement { | ||||
|             } else { | ||||
|                 replacement = alt; | ||||
|             } | ||||
|         } else if ("iframe".equals(name)) { | ||||
|             // @since $nap; make iframe non-empty | ||||
|             replacement = IFRAME_REPLACEMENT; | ||||
|         } else { | ||||
|             replacement = null; | ||||
|         } | ||||
|  | ||||
| @ -53,13 +53,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin { | ||||
|     public static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F; | ||||
| 
 | ||||
|     private final MarkwonHtmlRendererImpl.Builder builder; | ||||
|     private final MarkwonHtmlParser htmlParser; | ||||
| 
 | ||||
|     private MarkwonHtmlParser htmlParser; | ||||
|     private MarkwonHtmlRenderer htmlRenderer; | ||||
| 
 | ||||
|     // @since $nap; | ||||
|     private HtmlEmptyTagReplacement emptyTagReplacement = new HtmlEmptyTagReplacement(); | ||||
| 
 | ||||
|     @SuppressWarnings("WeakerAccess") | ||||
|     HtmlPlugin() { | ||||
|         this.builder = new MarkwonHtmlRendererImpl.Builder(); | ||||
|         this.htmlParser = MarkwonHtmlParserImpl.create(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -104,6 +107,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin { | ||||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param emptyTagReplacement {@link HtmlEmptyTagReplacement} | ||||
|      * @since $nap; | ||||
|      */ | ||||
|     @NonNull | ||||
|     public HtmlPlugin emptyTagReplacement(@NonNull HtmlEmptyTagReplacement emptyTagReplacement) { | ||||
|         this.emptyTagReplacement = emptyTagReplacement; | ||||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configurationBuilder) { | ||||
| 
 | ||||
| @ -128,6 +141,7 @@ public class HtmlPlugin extends AbstractMarkwonPlugin { | ||||
|             builder.addDefaultTagHandler(new HeadingHandler()); | ||||
|         } | ||||
| 
 | ||||
|         htmlParser = MarkwonHtmlParserImpl.create(emptyTagReplacement); | ||||
|         htmlRenderer = builder.build(); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										20
									
								
								markwon-spans-better/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								markwon-spans-better/build.gradle
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| apply plugin: 'com.android.library' | ||||
| 
 | ||||
| android { | ||||
| 
 | ||||
|     compileSdkVersion config['compile-sdk'] | ||||
|     buildToolsVersion config['build-tools'] | ||||
| 
 | ||||
|     defaultConfig { | ||||
|         minSdkVersion config['min-sdk'] | ||||
|         targetSdkVersion config['target-sdk'] | ||||
|         versionCode 1 | ||||
|         versionName version | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| dependencies { | ||||
|     api project(':markwon-core') | ||||
| } | ||||
| 
 | ||||
| registerArtifact(this) | ||||
							
								
								
									
										4
									
								
								markwon-spans-better/gradle.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								markwon-spans-better/gradle.properties
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| POM_NAME=Spans Better | ||||
| POM_ARTIFACT_ID=spans-better | ||||
| POM_DESCRIPTION=Better spans | ||||
| POM_PACKAGING=aar | ||||
							
								
								
									
										1
									
								
								markwon-spans-better/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								markwon-spans-better/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <manifest package="io.noties.markwon.spans.better" /> | ||||
| @ -0,0 +1,183 @@ | ||||
| package io.noties.markwon.spans.better; | ||||
| 
 | ||||
| import android.graphics.Canvas; | ||||
| import android.graphics.Color; | ||||
| import android.graphics.Paint; | ||||
| import android.graphics.Path; | ||||
| import android.os.Build; | ||||
| import android.text.Layout; | ||||
| import android.text.Spanned; | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.LineBackgroundSpan; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.RequiresApi; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.TextViewSpan; | ||||
| 
 | ||||
| import static java.lang.Math.max; | ||||
| import static java.lang.Math.min; | ||||
| 
 | ||||
| /** | ||||
|  * Credit goes to [Romain Guy](https://github.com/romainguy/elegant-underline) | ||||
|  * | ||||
|  * @since $nap; | ||||
|  */ | ||||
| public class BetterUnderlineSpan implements LineBackgroundSpan { | ||||
| 
 | ||||
|     public enum Type { | ||||
|         @RequiresApi(Build.VERSION_CODES.KITKAT) | ||||
|         PATH, | ||||
|         REGION | ||||
|     } | ||||
| 
 | ||||
|     private static final float UNDERLINE_CLEAR_GAP = 5.5F; | ||||
| 
 | ||||
|     private final Path underline = new Path(); | ||||
|     private final Path outline = new Path(); | ||||
|     private final Paint stroke = new Paint(); | ||||
|     private final Path strokedOutline = new Path(); | ||||
|     private char[] chars; | ||||
| 
 | ||||
|     BetterUnderlineSpan() { | ||||
|         stroke.setStyle(Paint.Style.FILL_AND_STROKE); | ||||
|         stroke.setStrokeCap(Paint.Cap.BUTT); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void drawBackground( | ||||
|             Canvas c, | ||||
|             Paint p, | ||||
|             int left, | ||||
|             int right, | ||||
|             int top, | ||||
|             int baseline, | ||||
|             int bottom, | ||||
|             CharSequence text, | ||||
|             int start, | ||||
|             int end, | ||||
|             int lnum | ||||
|     ) { | ||||
|         final Spanned spanned = (Spanned) text; | ||||
|         final TextView textView = TextViewSpan.textViewOf(spanned); | ||||
| 
 | ||||
|         if (textView == null) { | ||||
|             // no, cannot do it, the whole text will be changed | ||||
| //                p.setUnderlineText(true); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         final Layout layout = textView.getLayout(); | ||||
| 
 | ||||
|         final int selfStart = spanned.getSpanStart(this); | ||||
|         final int selfEnd = spanned.getSpanEnd(this); | ||||
| 
 | ||||
|         // TODO: also doesn't mean that it is last line, imagine text after span is ended | ||||
|         final boolean isLastLine = end == selfEnd || (selfEnd == (end - 1)); | ||||
| 
 | ||||
|         final int s = max(selfStart, start); | ||||
| 
 | ||||
|         // e - 1, but only if not last? | ||||
|         // oh... layout line count != span lines.. | ||||
|         final int e = min(selfEnd, end) - (isLastLine ? 0 : 1); | ||||
| 
 | ||||
|         final int l = (int) (layout.getPrimaryHorizontal(s) + .5F); | ||||
|         final int r = (int) (layout.getPrimaryHorizontal(e) + .5F); | ||||
|         final int b = getLineBottom(layout, lnum, isLastLine); | ||||
| 
 | ||||
|         final float density = textView.getResources().getDisplayMetrics().density; | ||||
| 
 | ||||
|         underline.rewind(); | ||||
|         // TODO: proper baseline | ||||
| //            underline.addRect( | ||||
| //                    l, b - (1.8F * density), | ||||
| //                    r, b, | ||||
| //                    Path.Direction.CW | ||||
| // | ||||
| //            ); | ||||
| 
 | ||||
|         // TODO: this must be configured somehow... | ||||
|         final int diff = (int) (p.descent() / 2F + .5F); | ||||
| 
 | ||||
|         underline.addRect( | ||||
|                 l, baseline + diff, | ||||
|                 r, baseline + diff + (density * 0.8F), | ||||
|                 Path.Direction.CW | ||||
|         ); | ||||
| 
 | ||||
| 
 | ||||
|         outline.rewind(); | ||||
| 
 | ||||
|         // reallocate only if less, otherwise re-use and then send actual indexes | ||||
|         // TODO: would this return proper array for the last line?! | ||||
|         chars = new char[e - s]; | ||||
|         TextUtils.getChars(spanned, s, e, chars, 0); | ||||
|         p.getTextPath( | ||||
|                 chars, | ||||
|                 0, (e - s), | ||||
|                 l, baseline, | ||||
|                 outline | ||||
|         ); | ||||
| 
 | ||||
|         final Paint paint = new Paint(); | ||||
|         paint.setStyle(Paint.Style.STROKE); | ||||
|         paint.setColor(Color.GREEN); | ||||
|         c.drawPath(outline, paint); | ||||
| 
 | ||||
|         outline.op(underline, Path.Op.INTERSECT); | ||||
| 
 | ||||
|         strokedOutline.rewind(); | ||||
|         stroke.setStrokeWidth(UNDERLINE_CLEAR_GAP * density); | ||||
|         stroke.getFillPath(outline, strokedOutline); | ||||
| 
 | ||||
|         underline.op(strokedOutline, Path.Op.DIFFERENCE); | ||||
| 
 | ||||
|         c.drawPath(underline, p); | ||||
|     } | ||||
| 
 | ||||
|     private static final float DEFAULT_EXTRA = 0F; | ||||
|     private static final float DEFAULT_MULTIPLIER = 1F; | ||||
| 
 | ||||
|     private static int getLineBottom(@NonNull Layout layout, int line, boolean isLastLine) { | ||||
| 
 | ||||
|         final int bottom = layout.getLineBottom(line); | ||||
|         final boolean lastLineSpacingNotAdded = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; | ||||
| 
 | ||||
|         // TODO: layout line count != span occupied lines | ||||
| //            final boolean isLastLine = line == layout.getLineCount() - 1; | ||||
| 
 | ||||
|         final int lineBottom; | ||||
|         final float lineSpacingExtra = layout.getSpacingAdd(); | ||||
|         final float lineSpacingMultiplier = layout.getSpacingMultiplier(); | ||||
| 
 | ||||
|         // simplified check | ||||
|         final boolean hasLineSpacing = lineSpacingExtra != DEFAULT_EXTRA | ||||
|                 || lineSpacingMultiplier != DEFAULT_MULTIPLIER; | ||||
| 
 | ||||
|         if (!hasLineSpacing | ||||
|                 || (isLastLine && lastLineSpacingNotAdded)) { | ||||
|             lineBottom = bottom; | ||||
|         } else { | ||||
|             final float extra; | ||||
|             if (Float.compare(DEFAULT_MULTIPLIER, lineSpacingMultiplier) != 0) { | ||||
|                 final int lineHeight = getLineHeight(layout, line); | ||||
|                 extra = lineHeight - | ||||
|                         ((lineHeight - lineSpacingExtra) / lineSpacingMultiplier); | ||||
|             } else { | ||||
|                 extra = lineSpacingExtra; | ||||
|             } | ||||
|             lineBottom = (int) (bottom - extra + .5F); | ||||
|         } | ||||
| 
 | ||||
|         if (isLastLine) { | ||||
|             return lineBottom - layout.getBottomPadding(); | ||||
|         } | ||||
| 
 | ||||
|         return lineBottom; | ||||
|     } | ||||
| 
 | ||||
|     private static int getLineHeight(@NonNull Layout layout, int line) { | ||||
|         return layout.getLineTop(line + 1) - layout.getLineTop(line); | ||||
|     } | ||||
| } | ||||
| @ -24,7 +24,11 @@ | ||||
|         <activity android:name="io.noties.markwon.sample.basicplugins.BasicPluginsActivity" /> | ||||
|         <activity android:name="io.noties.markwon.sample.recycler.RecyclerActivity" /> | ||||
|         <activity android:name="io.noties.markwon.sample.theme.ThemeActivity" /> | ||||
|         <activity android:name=".html.HtmlActivity" /> | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".html.HtmlActivity" | ||||
|             android:exported="true" /> | ||||
| 
 | ||||
|         <activity android:name=".simpleext.SimpleExtActivity" /> | ||||
|         <activity android:name=".customextension2.CustomExtensionActivity2" /> | ||||
|         <activity android:name=".precomputed.PrecomputedActivity" /> | ||||
| @ -32,6 +36,7 @@ | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".editor.EditorActivity" | ||||
|             android:exported="true" | ||||
|             android:windowSoftInputMode="adjustResize" /> | ||||
| 
 | ||||
|         <activity android:name=".inlineparser.InlineParserActivity" /> | ||||
|  | ||||
| @ -434,4 +434,25 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions { | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
| //    private void code() { | ||||
| //        final String md = "" + | ||||
| //                "hello `there`!\n\n" + | ||||
| //                "so this, `is super duper long very very very long line that should be going further and further and further down` yep.\n\n" + | ||||
| //                "`okay`"; | ||||
| //        final Markwon markwon = Markwon.builder(this) | ||||
| //                .usePlugin(new AbstractMarkwonPlugin() { | ||||
| //                    @Override | ||||
| //                    public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { | ||||
| //                        builder.setFactory(Code.class, new SpanFactory() { | ||||
| //                            @Override | ||||
| //                            public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps props) { | ||||
| //                                return new CodeTextView.CodeSpan(); | ||||
| //                            } | ||||
| //                        }); | ||||
| //                    } | ||||
| //                }) | ||||
| //                .build(); | ||||
| //        markwon.setMarkdown(textView, md); | ||||
| //    } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,192 @@ | ||||
| package io.noties.markwon.sample.basicplugins; | ||||
| 
 | ||||
| import android.annotation.SuppressLint; | ||||
| import android.content.Context; | ||||
| import android.graphics.Canvas; | ||||
| import android.graphics.Paint; | ||||
| import android.graphics.RectF; | ||||
| import android.os.Build; | ||||
| import android.text.Layout; | ||||
| import android.text.Spanned; | ||||
| import android.util.AttributeSet; | ||||
| import android.view.View; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import io.noties.debug.Debug; | ||||
| 
 | ||||
| @SuppressLint("AppCompatCustomView") | ||||
| public class CodeTextView extends TextView { | ||||
| 
 | ||||
|     static class CodeSpan { | ||||
|     } | ||||
| 
 | ||||
|     private int paddingHorizontal; | ||||
|     private int paddingVertical; | ||||
| 
 | ||||
|     private float cornerRadius; | ||||
|     private float strokeWidth; | ||||
|     private int strokeColor; | ||||
|     private int backgroundColor; | ||||
| 
 | ||||
|     public CodeTextView(Context context) { | ||||
|         super(context); | ||||
|         init(context, null); | ||||
|     } | ||||
| 
 | ||||
|     public CodeTextView(Context context, AttributeSet attrs) { | ||||
|         super(context, attrs); | ||||
|         init(context, attrs); | ||||
|     } | ||||
| 
 | ||||
|     private void init(Context context, @Nullable AttributeSet attrs) { | ||||
|         paint.setColor(0xFFff0000); | ||||
|         paint.setStyle(Paint.Style.FILL); | ||||
|     } | ||||
| 
 | ||||
|     private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onDraw(Canvas canvas) { | ||||
|         final Layout layout = getLayout(); | ||||
|         if (layout != null) { | ||||
|             draw(this, canvas, layout); | ||||
|         } | ||||
|         super.onDraw(canvas); | ||||
|     } | ||||
| 
 | ||||
|     private void draw( | ||||
|             @NonNull View view, | ||||
|             @NonNull Canvas canvas, | ||||
|             @NonNull Layout layout | ||||
|     ) { | ||||
| 
 | ||||
|         final CharSequence cs = layout.getText(); | ||||
|         if (!(cs instanceof Spanned)) { | ||||
|             return; | ||||
|         } | ||||
|         final Spanned spanned = (Spanned) cs; | ||||
| 
 | ||||
|         final int save = canvas.save(); | ||||
|         try { | ||||
|             canvas.translate(view.getPaddingLeft(), view.getPaddingTop()); | ||||
| 
 | ||||
|             // TODO: block? | ||||
|             // TODO: we must remove _original_ spans | ||||
|             // TODO: cache (attach a listener?) | ||||
|             // TODO: editor? | ||||
| 
 | ||||
|             final CodeSpan[] spans = spanned.getSpans(0, spanned.length(), CodeSpan.class); | ||||
|             if (spans != null && spans.length > 0) { | ||||
|                 for (CodeSpan span : spans) { | ||||
| 
 | ||||
|                     final int startOffset = spanned.getSpanStart(span); | ||||
|                     final int endOffset = spanned.getSpanEnd(span); | ||||
| 
 | ||||
|                     final int startLine = layout.getLineForOffset(startOffset); | ||||
|                     final int endLine = layout.getLineForOffset(endOffset); | ||||
| 
 | ||||
|                     // do we need to round them? | ||||
|                     final float left = layout.getPrimaryHorizontal(startOffset) | ||||
|                             + (-1 * layout.getParagraphDirection(startLine) * paddingHorizontal); | ||||
| 
 | ||||
|                     final float right = layout.getPrimaryHorizontal(endOffset) | ||||
|                             + (layout.getParagraphDirection(endLine) * paddingHorizontal); | ||||
| 
 | ||||
|                     final float top = getLineTop(layout, startLine, paddingVertical); | ||||
|                     final float bottom = getLineBottom(layout, endLine, paddingVertical); | ||||
| 
 | ||||
|                     Debug.i(new RectF(left, top, right, bottom).toShortString()); | ||||
| 
 | ||||
|                     if (startLine == endLine) { | ||||
|                         canvas.drawRect(left, top, right, bottom, paint); | ||||
|                     } else { | ||||
|                         // draw first line (start until the lineEnd) | ||||
|                         // draw everything in-between (startLine - endLine) | ||||
|                         // draw last line (lineStart until the end | ||||
| 
 | ||||
|                         canvas.drawRect( | ||||
|                                 left, | ||||
|                                 top, | ||||
|                                 layout.getLineRight(startLine), | ||||
|                                 getLineBottom(layout, startLine, paddingVertical), | ||||
|                                 paint | ||||
|                         ); | ||||
| 
 | ||||
|                         for (int line = startLine + 1; line < endLine; line++) { | ||||
|                             canvas.drawRect( | ||||
|                                     layout.getLineLeft(line), | ||||
|                                     getLineTop(layout, line, paddingVertical), | ||||
|                                     layout.getLineRight(line), | ||||
|                                     getLineBottom(layout, line, paddingVertical), | ||||
|                                     paint | ||||
|                             ); | ||||
|                         } | ||||
| 
 | ||||
|                         canvas.drawRect( | ||||
|                                 layout.getLineLeft(endLine), | ||||
|                                 getLineTop(layout, endLine, paddingVertical), | ||||
|                                 right, | ||||
|                                 getLineBottom(layout, endLine, paddingVertical), | ||||
|                                 paint | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } finally { | ||||
|             canvas.restoreToCount(save); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineTop(@NonNull Layout layout, int line, float padding) { | ||||
|         float value = layout.getLineTop(line) - padding; | ||||
|         if (line == 0) { | ||||
|             value -= layout.getTopPadding(); | ||||
|         } | ||||
|         return value; | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineBottom(@NonNull Layout layout, int line, float padding) { | ||||
|         float value = getLineBottomWithoutSpacing(layout, line) - padding; | ||||
|         if (line == (layout.getLineCount() - 1)) { | ||||
|             value -= layout.getBottomPadding(); | ||||
|         } | ||||
|         return value; | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineBottomWithoutSpacing(@NonNull Layout layout, int line) { | ||||
|         final float value = layout.getLineBottom(line); | ||||
| 
 | ||||
|         final boolean isLastLineSpacingNotAdded = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; | ||||
|         final boolean isLastLine = line == (layout.getLineCount() - 1); | ||||
| 
 | ||||
|         final float lineBottomWithoutSpacing; | ||||
| 
 | ||||
|         final float lineSpacingExtra = layout.getSpacingAdd(); | ||||
|         final float lineSpacingMultiplier = layout.getSpacingMultiplier(); | ||||
| 
 | ||||
|         final boolean hasLineSpacing = Float.compare(lineSpacingExtra, .0F) != 0 | ||||
|                 || Float.compare(lineSpacingMultiplier, 1F) != 0; | ||||
| 
 | ||||
|         if (!hasLineSpacing || isLastLine && isLastLineSpacingNotAdded) { | ||||
|             lineBottomWithoutSpacing = value; | ||||
|         } else { | ||||
|             final float extra; | ||||
|             if (Float.compare(lineSpacingMultiplier, 1F) != 0) { | ||||
|                 final float lineHeight = getLineHeight(layout, line); | ||||
|                 extra = lineHeight - (lineHeight - lineSpacingExtra) / lineSpacingMultiplier; | ||||
|             } else { | ||||
|                 extra = lineSpacingExtra; | ||||
|             } | ||||
|             lineBottomWithoutSpacing = value - extra; | ||||
|         } | ||||
| 
 | ||||
|         return lineBottomWithoutSpacing; | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineHeight(@NonNull Layout layout, int line) { | ||||
|         return layout.getLineTop(line + 1) - layout.getLineTop(line); | ||||
|     } | ||||
| } | ||||
| @ -6,6 +6,7 @@ import android.text.SpannableStringBuilder; | ||||
| import android.text.Spanned; | ||||
| import android.text.TextPaint; | ||||
| import android.text.TextUtils; | ||||
| import android.text.TextWatcher; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.text.style.ForegroundColorSpan; | ||||
| import android.text.style.MetricAffectingSpan; | ||||
| @ -25,8 +26,11 @@ import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.Executors; | ||||
| 
 | ||||
| import io.noties.debug.AndroidLogDebugOutput; | ||||
| import io.noties.debug.Debug; | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.SoftBreakAddsNewLinePlugin; | ||||
| import io.noties.markwon.core.spans.EmphasisSpan; | ||||
| import io.noties.markwon.core.spans.StrongEmphasisSpan; | ||||
| import io.noties.markwon.editor.AbstractEditHandler; | ||||
| @ -65,7 +69,8 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                 .add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin) | ||||
|                 .add("pluginRequire", this::plugin_require) | ||||
|                 .add("pluginNoDefaults", this::plugin_no_defaults) | ||||
|                 .add("heading", this::heading); | ||||
|                 .add("heading", this::heading) | ||||
|                 .add("newLine", this::newLine); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
| @ -98,7 +103,10 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|         super.onCreate(savedInstanceState); | ||||
|         createView(); | ||||
| 
 | ||||
|         Debug.init(new AndroidLogDebugOutput(true)); | ||||
| 
 | ||||
|         multiple_edit_spans(); | ||||
| //        newLine(); | ||||
|     } | ||||
| 
 | ||||
|     private void simple_process() { | ||||
| @ -230,6 +238,7 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                         builder.inlineParserFactory(inlineParserFactory); | ||||
|                     } | ||||
|                 }) | ||||
|                 .usePlugin(SoftBreakAddsNewLinePlugin.create()) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); | ||||
| @ -280,6 +289,13 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                 editor, Executors.newSingleThreadExecutor(), editText)); | ||||
|     } | ||||
| 
 | ||||
|     private void newLine() { | ||||
|         final Markwon markwon = Markwon.create(this); | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
|         final TextWatcher textWatcher = MarkdownNewLine.wrap(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|         editText.addTextChangedListener(textWatcher); | ||||
|     } | ||||
| 
 | ||||
|     private void plugin_require() { | ||||
|         // usage of plugin from other plugins | ||||
| 
 | ||||
| @ -295,6 +311,8 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         editText.setMovementMethod(LinkMovementMethod.getInstance()); | ||||
| 
 | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
| 
 | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( | ||||
|  | ||||
| @ -0,0 +1,129 @@ | ||||
| package io.noties.markwon.sample.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.text.TextUtils; | ||||
| import android.text.TextWatcher; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| import io.noties.debug.Debug; | ||||
| 
 | ||||
| abstract class MarkdownNewLine { | ||||
| 
 | ||||
|     @NonNull | ||||
|     static TextWatcher wrap(@NonNull TextWatcher textWatcher) { | ||||
|         return new NewLineTextWatcher(textWatcher); | ||||
|     } | ||||
| 
 | ||||
|     private MarkdownNewLine() { | ||||
|     } | ||||
| 
 | ||||
|     private static class NewLineTextWatcher implements TextWatcher { | ||||
| 
 | ||||
|         // NB! matches only bullet lists | ||||
|         private final Pattern RE = Pattern.compile("^( {0,3}[\\-+* ]+)(.+)*$"); | ||||
| 
 | ||||
|         private final TextWatcher wrapped; | ||||
| 
 | ||||
|         private boolean selfChange; | ||||
| 
 | ||||
|         // this content is pending to be inserted at the beginning | ||||
|         private String pendingNewLineContent; | ||||
|         private int pendingNewLineIndex; | ||||
| 
 | ||||
|         // mark current edited line for removal (range start/end) | ||||
|         private int clearLineStart; | ||||
|         private int clearLineEnd; | ||||
| 
 | ||||
|         NewLineTextWatcher(@NonNull TextWatcher wrapped) { | ||||
|             this.wrapped = wrapped; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void beforeTextChanged(CharSequence s, int start, int count, int after) { | ||||
|             // no op | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void onTextChanged(CharSequence s, int start, int before, int count) { | ||||
|             if (selfChange) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // just one new character added | ||||
|             if (before == 0 | ||||
|                     && count == 1 | ||||
|                     && '\n' == s.charAt(start)) { | ||||
|                 int end = -1; | ||||
|                 for (int i = start - 1; i >= 0; i--) { | ||||
|                     if ('\n' == s.charAt(i)) { | ||||
|                         end = i + 1; | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 // start at the very beginning | ||||
|                 if (end < 0) { | ||||
|                     end = 0; | ||||
|                 } | ||||
| 
 | ||||
|                 final String pendingNewLineContent; | ||||
| 
 | ||||
|                 final int clearLineStart; | ||||
|                 final int clearLineEnd; | ||||
| 
 | ||||
|                 final Matcher matcher = RE.matcher(s.subSequence(end, start)); | ||||
|                 if (matcher.matches()) { | ||||
|                     // if second group is empty -> remove new line | ||||
|                     final String content = matcher.group(2); | ||||
|                     Debug.e("new line, content: '%s'", content); | ||||
|                     if (TextUtils.isEmpty(content)) { | ||||
|                         // another empty new line, remove this start | ||||
|                         clearLineStart = end; | ||||
|                         clearLineEnd = start; | ||||
|                         pendingNewLineContent = null; | ||||
|                     } else { | ||||
|                         pendingNewLineContent = matcher.group(1); | ||||
|                         clearLineStart = clearLineEnd = 0; | ||||
|                     } | ||||
|                 } else { | ||||
|                     pendingNewLineContent = null; | ||||
|                     clearLineStart = clearLineEnd = 0; | ||||
|                 } | ||||
|                 this.pendingNewLineContent = pendingNewLineContent; | ||||
|                 this.pendingNewLineIndex = start + 1; | ||||
|                 this.clearLineStart = clearLineStart; | ||||
|                 this.clearLineEnd = clearLineEnd; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void afterTextChanged(Editable s) { | ||||
|             if (selfChange) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (pendingNewLineContent != null || clearLineStart < clearLineEnd) { | ||||
|                 selfChange = true; | ||||
|                 try { | ||||
|                     if (pendingNewLineContent != null) { | ||||
|                         s.insert(pendingNewLineIndex, pendingNewLineContent); | ||||
|                         pendingNewLineContent = null; | ||||
|                     } else { | ||||
|                         s.replace(clearLineStart, clearLineEnd, ""); | ||||
|                         clearLineStart = clearLineEnd = 0; | ||||
|                     } | ||||
|                 } finally { | ||||
|                     selfChange = false; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             // NB, we assume MarkdownEditor text watcher that only listens for this event, | ||||
|             // other text-watchers must be interested in other events also | ||||
|             wrapped.afterTextChanged(s); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,242 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.graphics.Canvas; | ||||
| import android.graphics.Paint; | ||||
| import android.graphics.Path; | ||||
| import android.os.Build; | ||||
| import android.text.Layout; | ||||
| import android.text.Spanned; | ||||
| import android.text.TextPaint; | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.LineBackgroundSpan; | ||||
| import android.text.style.MetricAffectingSpan; | ||||
| import android.util.Log; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Px; | ||||
| import androidx.annotation.RequiresApi; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.TextLayoutSpan; | ||||
| import io.noties.markwon.core.spans.TextViewSpan; | ||||
| 
 | ||||
| import static java.lang.Math.max; | ||||
| import static java.lang.Math.min; | ||||
| 
 | ||||
| /** | ||||
|  * Credit goes to [Romain Guy](https://github.com/romainguy/elegant-underline) | ||||
|  * <p> | ||||
|  * Failed attempt to create elegant underline as a span | ||||
|  * <ul> | ||||
|  * <li>in a `TextView` span is rendered, but `draw` method is invoked constantly which put pressure on CPU and memory | ||||
|  * <li>in an `EditText` only the first line draws this underline span (seems to be a weird | ||||
|  * issue between LineBackgroundSpan and EditText). Also, in `EditText` `draw` method is invoked | ||||
|  * constantly (for each drawing of the blinking cursor) | ||||
|  * <li>cannot reliably receive proper text, for example if underline is applied to a text range which has | ||||
|  * different typefaces applied to different words (underline cannot know that, which applied to which) | ||||
|  * </ul> | ||||
|  */ | ||||
| // will apply other spans that 100% contain this one, so for example if | ||||
| // an underline that inside some other spans (different typeface), they won't be applied and thus | ||||
| // underline would be incorrect | ||||
| // do not use in editor, due to some obscure thing, LineBackgroundSpan would be applied to the first line only | ||||
| // also, in editor this span would be redrawn with each blink of the cursor | ||||
| @RequiresApi(Build.VERSION_CODES.KITKAT) | ||||
| class ElegantUnderlineSpan implements LineBackgroundSpan { | ||||
| 
 | ||||
|     private static final float DEFAULT_UNDERLINE_HEIGHT_DIP = 0.8F; | ||||
|     private static final float DEFAULT_UNDERLINE_CLEAR_GAP_DIP = 5.5F; | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ElegantUnderlineSpan create() { | ||||
|         return new ElegantUnderlineSpan(0, 0); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ElegantUnderlineSpan create(@Px int underlineHeight) { | ||||
|         return new ElegantUnderlineSpan(underlineHeight, 0); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ElegantUnderlineSpan create(@Px int underlineHeight, @Px int underlineClearGap) { | ||||
|         return new ElegantUnderlineSpan(underlineHeight, underlineClearGap); | ||||
|     } | ||||
| 
 | ||||
|     // TODO: underline color? | ||||
|     private final int underlineHeight; | ||||
|     private final int underlineClearGap; | ||||
| 
 | ||||
|     private final Path underline = new Path(); | ||||
|     private final Path outline = new Path(); | ||||
|     private final Paint stroke = new Paint(); | ||||
|     private final Path strokedOutline = new Path(); | ||||
| 
 | ||||
|     private final CharCache charCache = new CharCache(); | ||||
| 
 | ||||
|     private final TextPaint tempTextPaint = new TextPaint(); | ||||
| 
 | ||||
|     protected ElegantUnderlineSpan(@Px int underlineHeight, @Px int underlineClearGap) { | ||||
|         this.underlineHeight = underlineHeight; | ||||
|         this.underlineClearGap = underlineClearGap; | ||||
|         stroke.setStyle(Paint.Style.FILL_AND_STROKE); | ||||
|         stroke.setStrokeCap(Paint.Cap.BUTT); | ||||
|     } | ||||
| 
 | ||||
|     // is it possible that LineBackgroundSpan is not receiving proper spans? like typeface? | ||||
|     //  it complicates things (like the need to have own copy of paint) | ||||
| 
 | ||||
|     // is it possible that LineBackgroundSpan is called constantly even in a TextView? | ||||
| 
 | ||||
|     @Override | ||||
|     public void drawBackground( | ||||
|             Canvas c, | ||||
|             Paint p, | ||||
|             int left, | ||||
|             int right, | ||||
|             int top, | ||||
|             int baseline, | ||||
|             int bottom, | ||||
|             CharSequence text, | ||||
|             int start, | ||||
|             int end, | ||||
|             int lnum | ||||
|     ) { | ||||
| 
 | ||||
| //        Debug.trace(); | ||||
| 
 | ||||
|         final Spanned spanned = (Spanned) text; | ||||
|         final TextView textView = TextViewSpan.textViewOf(spanned); | ||||
| 
 | ||||
|         if (textView == null) { | ||||
|             // TextView is required | ||||
|             Log.e("EU", "no text view"); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         final Layout layout; | ||||
|         { | ||||
|             // check if there is dedicated layout, if not, use from textView | ||||
|             //  (think tableRowSpan that uses own Layout) | ||||
|             final Layout layoutFromSpan = TextLayoutSpan.layoutOf(spanned); | ||||
|             if (layoutFromSpan != null) { | ||||
|                 layout = layoutFromSpan; | ||||
|             } else { | ||||
|                 layout = textView.getLayout(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (layout == null) { | ||||
|             // we could call `p.setUnderlineText(true)` here a fallback, | ||||
|             //  but this would make __all__ text in a TextView underlined, which is not | ||||
|             //  what we want | ||||
|             Log.e("EU", "no layout"); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         tempTextPaint.set((TextPaint) p); | ||||
| 
 | ||||
|         // we must use _selfStart_ because underline can start **not** at the beginning of a line. | ||||
|         // as we are using LineBackground `start` would indicate the start position of the line | ||||
|         //  and not start of the span (self). The same goes for selfEnd (ended before line) | ||||
|         final int selfStart = spanned.getSpanStart(this); | ||||
|         final int selfEnd = spanned.getSpanEnd(this); | ||||
| 
 | ||||
|         final int s = max(selfStart, start); | ||||
| 
 | ||||
|         // all lines should use (end - 1) to receive proper line end coordinate X, | ||||
|         //  unless it is last line in _layout_ | ||||
|         final boolean isLastLine = lnum == (layout.getLineCount() - 1); | ||||
|         final int e = min(selfEnd, end - (isLastLine ? 0 : 1)); | ||||
| 
 | ||||
|         if (true) { | ||||
|             Log.e("EU", String.format("lnum: %s, hash: %s, text: '%s'", | ||||
|                     lnum, text.subSequence(s, e).hashCode(), text.subSequence(s, e))); | ||||
|         } | ||||
| 
 | ||||
|         final int leading; | ||||
|         final int trailing; | ||||
|         { | ||||
|             final int l = (int) (layout.getPrimaryHorizontal(s) + .5F); | ||||
|             final int r = (int) (layout.getPrimaryHorizontal(e) + .5F); | ||||
|             leading = min(l, r); | ||||
|             trailing = max(l, r); | ||||
|         } | ||||
| 
 | ||||
|         underline.rewind(); | ||||
| 
 | ||||
|         // middle between baseline and descent | ||||
|         final int diff = (int) (p.descent() / 2F + .5F); | ||||
| 
 | ||||
|         underline.addRect( | ||||
|                 leading, baseline + diff, | ||||
|                 trailing, baseline + diff + underlineHeight(textView), | ||||
|                 Path.Direction.CW | ||||
|         ); | ||||
| 
 | ||||
|         outline.rewind(); | ||||
| 
 | ||||
|         final int charsLength = e - s; | ||||
|         final char[] chars = charCache.chars(charsLength); | ||||
|         TextUtils.getChars(spanned, s, e, chars, 0); | ||||
| 
 | ||||
|         if (true) { | ||||
|             final MetricAffectingSpan[] metricAffectingSpans = spanned.getSpans(s, e, MetricAffectingSpan.class); | ||||
| //            Log.e("EU", Arrays.toString(metricAffectingSpans)); | ||||
|             for (MetricAffectingSpan span : metricAffectingSpans) { | ||||
|                 span.updateMeasureState(tempTextPaint); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // todo: styleSpan | ||||
|         // todo all other spans (maybe UpdateMeasureSpans?) | ||||
|         tempTextPaint.getTextPath( | ||||
|                 chars, | ||||
|                 0, charsLength, | ||||
|                 leading, baseline, | ||||
|                 outline | ||||
|         ); | ||||
| 
 | ||||
|         outline.op(underline, Path.Op.INTERSECT); | ||||
| 
 | ||||
|         strokedOutline.rewind(); | ||||
|         stroke.setStrokeWidth(underlineClearGap(textView)); | ||||
|         stroke.getFillPath(outline, strokedOutline); | ||||
| 
 | ||||
|         underline.op(strokedOutline, Path.Op.DIFFERENCE); | ||||
| 
 | ||||
|         c.drawPath(underline, p); | ||||
|     } | ||||
| 
 | ||||
|     private int underlineHeight(@NonNull TextView textView) { | ||||
|         if (underlineHeight > 0) { | ||||
|             return underlineHeight; | ||||
|         } | ||||
|         return (int) (DEFAULT_UNDERLINE_HEIGHT_DIP * textView.getResources().getDisplayMetrics().density + 0.5F); | ||||
|     } | ||||
| 
 | ||||
|     private int underlineClearGap(@NonNull TextView textView) { | ||||
|         if (underlineClearGap > 0) { | ||||
|             return underlineClearGap; | ||||
|         } | ||||
|         return (int) (DEFAULT_UNDERLINE_CLEAR_GAP_DIP * textView.getResources().getDisplayMetrics().density + 0.5F); | ||||
|     } | ||||
| 
 | ||||
|     // primitive cache that grows internal array (never shrinks, nor clear buffer) | ||||
|     // TODO: but... each span has own instance, so not much of the memory saving | ||||
|     private static class CharCache { | ||||
| 
 | ||||
|         @NonNull | ||||
|         char[] chars(int ofLength) { | ||||
|             final char[] out; | ||||
|             if (chars == null || chars.length < ofLength) { | ||||
|                 out = chars = new char[ofLength]; | ||||
|             } else { | ||||
|                 out = chars; | ||||
|             } | ||||
|             return out; | ||||
|         } | ||||
| 
 | ||||
|         private char[] chars; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @ -1,18 +1,18 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.os.Build; | ||||
| import android.os.Bundle; | ||||
| import android.text.Layout; | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.AbsoluteSizeSpan; | ||||
| import android.text.style.AlignmentSpan; | ||||
| import android.widget.TextView; | ||||
| import android.widget.Toast; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.annotation.Px; | ||||
| 
 | ||||
| import org.commonmark.node.Paragraph; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.Random; | ||||
| @ -23,6 +23,7 @@ import io.noties.markwon.MarkwonConfiguration; | ||||
| import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.RenderProps; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.html.HtmlEmptyTagReplacement; | ||||
| import io.noties.markwon.html.HtmlPlugin; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.MarkwonHtmlRenderer; | ||||
| @ -42,7 +43,10 @@ public class HtmlActivity extends ActivityWithMenuOptions { | ||||
|                 .add("align", this::align) | ||||
|                 .add("randomCharSize", this::randomCharSize) | ||||
|                 .add("enhance", this::enhance) | ||||
|                 .add("image", this::image); | ||||
|                 .add("image", this::image) | ||||
|                 .add("elegantUnderline", this::elegantUnderline) | ||||
|                 .add("iframe", this::iframe) | ||||
|                 .add("emptyTagReplacement", this::emptyTagReplacement); | ||||
|     } | ||||
| 
 | ||||
|     private TextView textView; | ||||
| @ -57,7 +61,7 @@ public class HtmlActivity extends ActivityWithMenuOptions { | ||||
| 
 | ||||
|         textView = findViewById(R.id.text_view); | ||||
| 
 | ||||
|         align(); | ||||
|         elegantUnderline(); | ||||
|     } | ||||
| 
 | ||||
|     // we can use `SimpleTagHandler` for _simple_ cases (when the whole tag content | ||||
| @ -268,4 +272,86 @@ public class HtmlActivity extends ActivityWithMenuOptions { | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void elegantUnderline() { | ||||
| 
 | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { | ||||
|             Toast.makeText( | ||||
|                     this, | ||||
|                     "Elegant underline is supported on KitKat and up", | ||||
|                     Toast.LENGTH_LONG | ||||
|             ).show(); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         final String underline = "Well well wel, and now <u>Gogogo, quite **perfect** yeah</u> and nice and elegant"; | ||||
| 
 | ||||
|         final String md = "" + | ||||
|                 underline + "\n\n" + | ||||
|                 "<b>" + underline + "</b>\n\n" + | ||||
|                 "<font name=serif>" + underline + "</font>\n\n" + | ||||
|                 "<font name=sans-serif>" + underline + underline + underline + "</font>\n\n" + | ||||
|                 "<font name=monospace>" + underline + "</font>\n\n" + | ||||
|                 ""; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(HtmlPlugin.create(plugin -> plugin | ||||
|                         .addHandler(new HtmlFontTagHandler()) | ||||
|                         .addHandler(new HtmlElegantUnderlineTagHandler()))) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void iframe() { | ||||
|         final String md = "" + | ||||
|                 "# Hello iframe\n\n" + | ||||
|                 "<p class=\"p1\"><img title=\"JUMP FORCE\" src=\"https://img1.ak.crunchyroll.com/i/spire1/f0c009039dd9f8dff5907fff148adfca1587067000_full.jpg\" alt=\"JUMP FORCE\" width=\"640\" height=\"362\" /></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">Switch owners will soon get to take part in the ultimate <em>Shonen Jump </em>rumble. Bandai Namco announced plans to bring <strong><em>Jump Force </em></strong>to <strong>Switch</strong> as <strong><em>Jump Force Deluxe Edition</em></strong>, with a release set for sometime this year. This version will include all of the original playable characters and those from Character Pass 1, and <strong>Character Pass 2 is also in the works </strong>for all versions, starting with <strong>Shoto Todoroki from </strong><span style=\"color: #ff9900;\"><a href=\"/my-hero-academia?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><strong><em>My Hero Academia</em></strong></span></a></span>.</p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">Other than Todoroki, Bandai Namco hinted that the four other Character Pass 2 characters will hail from <span style=\"color: #ff9900;\"><a href=\"/hunter-x-hunter?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>Hunter x Hunter</em></span></a></span>, <em>Yu Yu Hakusho</em>, <span style=\"color: #ff9900;\"><a href=\"/bleach?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>Bleach</em></span></a></span>, and <span style=\"color: #ff9900;\"><a href=\"/jojos-bizarre-adventure?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>JoJo's Bizarre Adventure</em></span></a></span>. Character Pass 2 will be priced at $17.99, and Todoroki launches this spring.<span class=\"Apple-converted-space\"> </span></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\"><iframe style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://www.youtube.com/embed/At1qTj-LWCc\" frameborder=\"0\" width=\"640\" height=\"360\"></iframe></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">Character Pass 2 promo:</p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\"><iframe style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://www.youtube.com/embed/CukwN6kV4R4\" frameborder=\"0\" width=\"640\" height=\"360\"></iframe></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\"><a href=\"https://got.cr/PremiumTrial-NewsBanner4\"><img style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://img1.ak.crunchyroll.com/i/spire4/78f5441d927cf160a93e037b567c2b1f1587067041_full.png\" alt=\"\" width=\"640\" height=\"43\" /></a></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">-------</p>\n" + | ||||
|                 "<p class=\"p1\"><em>Joseph Luster is the Games and Web editor at </em><a href=\"http://www.otakuusamagazine.com/ME2/Default.asp\"><em>Otaku USA Magazine</em></a><em>. You can read his webcomic, </em><a href=\"http://subhumanzoids.com/comics/big-dumb-fighting-idiots/\">BIG DUMB FIGHTING IDIOTS</a><em> at </em><a href=\"http://subhumanzoids.com/\"><em>subhumanzoids</em></a><em>. Follow him on Twitter </em><a href=\"https://twitter.com/Moldilox\"><em>@Moldilox</em></a><em>.</em><span class=\"Apple-converted-space\"> </span></p>"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(ImagesPlugin.create()) | ||||
|                 .usePlugin(HtmlPlugin.create()) | ||||
|                 .usePlugin(new IFrameHtmlPlugin()) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void emptyTagReplacement() { | ||||
| 
 | ||||
|         final String md = "" + | ||||
|                 "<empty></empty> the `<empty></empty>` is replaced?"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(HtmlPlugin.create(plugin -> { | ||||
|                     plugin.emptyTagReplacement(new HtmlEmptyTagReplacement() { | ||||
|                         @Nullable | ||||
|                         @Override | ||||
|                         public String replace(@NonNull HtmlTag tag) { | ||||
|                             if ("empty".equals(tag.name())) { | ||||
|                                 return "REPLACED_EMPTY_WITH_IT"; | ||||
|                             } | ||||
|                             return super.replace(tag); | ||||
|                         } | ||||
|                     }); | ||||
|                 })) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,38 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.os.Build; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.RequiresApi; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.MarkwonHtmlRenderer; | ||||
| import io.noties.markwon.html.TagHandler; | ||||
| 
 | ||||
| @RequiresApi(Build.VERSION_CODES.KITKAT) | ||||
| public class HtmlElegantUnderlineTagHandler extends TagHandler { | ||||
| 
 | ||||
|     @Override | ||||
|     public void handle(@NonNull MarkwonVisitor visitor, @NonNull MarkwonHtmlRenderer renderer, @NonNull HtmlTag tag) { | ||||
|         if (tag.isBlock()) { | ||||
|             visitChildren(visitor, renderer, tag.getAsBlock()); | ||||
|         } | ||||
|         SpannableBuilder.setSpans( | ||||
|                 visitor.builder(), | ||||
|                 ElegantUnderlineSpan.create(), | ||||
|                 tag.start(), | ||||
|                 tag.end() | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<String> supportedTags() { | ||||
|         return Collections.singleton("u"); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,42 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.TypefaceSpan; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.MarkwonHtmlRenderer; | ||||
| import io.noties.markwon.html.TagHandler; | ||||
| 
 | ||||
| public class HtmlFontTagHandler extends TagHandler { | ||||
| 
 | ||||
|     @Override | ||||
|     public void handle(@NonNull MarkwonVisitor visitor, @NonNull MarkwonHtmlRenderer renderer, @NonNull HtmlTag tag) { | ||||
| 
 | ||||
|         if (tag.isBlock()) { | ||||
|             visitChildren(visitor, renderer, tag.getAsBlock()); | ||||
|         } | ||||
| 
 | ||||
|         final String font = tag.attributes().get("name"); | ||||
|         if (!TextUtils.isEmpty(font)) { | ||||
|             SpannableBuilder.setSpans( | ||||
|                     visitor.builder(), | ||||
|                     new TypefaceSpan(font), | ||||
|                     tag.start(), | ||||
|                     tag.end() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<String> supportedTags() { | ||||
|         return Collections.singleton("font"); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,48 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.node.Image; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| import io.noties.debug.Debug; | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.MarkwonConfiguration; | ||||
| import io.noties.markwon.RenderProps; | ||||
| import io.noties.markwon.html.HtmlPlugin; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.tag.SimpleTagHandler; | ||||
| import io.noties.markwon.image.ImageProps; | ||||
| import io.noties.markwon.image.ImageSize; | ||||
| 
 | ||||
| public class IFrameHtmlPlugin extends AbstractMarkwonPlugin { | ||||
|     @Override | ||||
|     public void configure(@NonNull Registry registry) { | ||||
|         registry.require(HtmlPlugin.class, htmlPlugin -> { | ||||
|             // TODO: empty tag replacement | ||||
|             htmlPlugin.addHandler(new EmbedTagHandler()); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private static class EmbedTagHandler extends SimpleTagHandler { | ||||
| 
 | ||||
|         @Nullable | ||||
|         @Override | ||||
|         public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull HtmlTag tag) { | ||||
|             final ImageSize imageSize = new ImageSize(new ImageSize.Dimension(640, "px"), new ImageSize.Dimension(480, "px")); | ||||
|             ImageProps.IMAGE_SIZE.set(renderProps, imageSize); | ||||
|             ImageProps.DESTINATION.set(renderProps, "https://hey.com/1.png"); | ||||
|             return configuration.spansFactory().require(Image.class) | ||||
|                     .getSpans(configuration, renderProps); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public Collection<String> supportedTags() { | ||||
|             return Collections.singleton("iframe"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -6,10 +6,14 @@ import android.widget.TextView; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.internal.inline.AsteriskDelimiterProcessor; | ||||
| import org.commonmark.internal.inline.UnderscoreDelimiterProcessor; | ||||
| import org.commonmark.node.Block; | ||||
| import org.commonmark.node.BlockQuote; | ||||
| import org.commonmark.node.FencedCodeBlock; | ||||
| import org.commonmark.node.Heading; | ||||
| import org.commonmark.node.HtmlBlock; | ||||
| import org.commonmark.node.IndentedCodeBlock; | ||||
| import org.commonmark.node.ListBlock; | ||||
| import org.commonmark.node.ThematicBreak; | ||||
| import org.commonmark.parser.InlineParserFactory; | ||||
| @ -22,7 +26,9 @@ import java.util.Set; | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.inlineparser.BackticksInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.BangInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.HtmlInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParser; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; | ||||
| import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; | ||||
| @ -41,7 +47,9 @@ public class InlineParserActivity extends ActivityWithMenuOptions { | ||||
|                 .add("links_only", this::links_only) | ||||
|                 .add("disable_code", this::disable_code) | ||||
|                 .add("pluginWithDefaults", this::pluginWithDefaults) | ||||
|                 .add("pluginNoDefaults", this::pluginNoDefaults); | ||||
|                 .add("pluginNoDefaults", this::pluginNoDefaults) | ||||
|                 .add("disableHtmlInlineParser", this::disableHtmlInlineParser) | ||||
|                 .add("disableHtmlSanitize", this::disableHtmlSanitize); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
| @ -173,4 +181,67 @@ public class InlineParserActivity extends ActivityWithMenuOptions { | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void disableHtmlInlineParser() { | ||||
|         final String md = "# Html <b>disabled</b>\n\n" + | ||||
|                 "<em>emphasis <strong>strong</strong>\n\n" + | ||||
|                 "<p>paragraph <img src='hey.jpg' /></p>\n\n" + | ||||
|                 "<test></test>\n\n" + | ||||
|                 "<test>"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(MarkwonInlineParserPlugin.create()) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configure(@NonNull Registry registry) { | ||||
|                         // NB! `AsteriskDelimiterProcessor` and `UnderscoreDelimiterProcessor` | ||||
|                         //  handles both emphasis and strong-emphasis nodes | ||||
|                         registry.require(MarkwonInlineParserPlugin.class, plugin -> { | ||||
|                             plugin.factoryBuilder() | ||||
|                                     .excludeInlineProcessor(HtmlInlineProcessor.class) | ||||
|                                     .excludeInlineProcessor(BangInlineProcessor.class) | ||||
|                                     .excludeInlineProcessor(OpenBracketInlineProcessor.class) | ||||
|                                     .excludeDelimiterProcessor(AsteriskDelimiterProcessor.class) | ||||
|                                     .excludeDelimiterProcessor(UnderscoreDelimiterProcessor.class); | ||||
|                         }); | ||||
|                     } | ||||
| 
 | ||||
|                     @Override | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                         builder.enabledBlockTypes(new HashSet<>(Arrays.asList( | ||||
|                                 Heading.class, | ||||
| //                        HtmlBlock.class, | ||||
|                                 ThematicBreak.class, | ||||
|                                 FencedCodeBlock.class, | ||||
|                                 IndentedCodeBlock.class, | ||||
|                                 BlockQuote.class, | ||||
|                                 ListBlock.class | ||||
|                         ))); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void disableHtmlSanitize() { | ||||
|         final String md = "# Html <b>disabled</b>\n\n" + | ||||
|                 "<em>emphasis <strong>strong</strong>\n\n" + | ||||
|                 "<p>paragraph <img src='hey.jpg' /></p>\n\n" + | ||||
|                 "<test></test>\n\n" + | ||||
|                 "<test>"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @NonNull | ||||
|                     @Override | ||||
|                     public String processMarkdown(@NonNull String markdown) { | ||||
|                         return markdown | ||||
|                                 .replaceAll("<", "<") | ||||
|                                 .replaceAll(">", ">"); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -16,5 +16,6 @@ include ':app', ':sample', | ||||
|         ':markwon-recycler', | ||||
|         ':markwon-recycler-table', | ||||
|         ':markwon-simple-ext', | ||||
|         ':markwon-spans-better', | ||||
|         ':markwon-syntax-highlight', | ||||
|         ':markwon-test-span' | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Dimitry Ivanov
						Dimitry Ivanov