From b497f872e5920087f0304d522a2868eb5fab02ea Mon Sep 17 00:00:00 2001 From: Dimitry Ivanov Date: Tue, 28 Apr 2020 16:57:43 +0300 Subject: [PATCH] TextViewSpan and TextLayoutSpan --- CHANGELOG.md | 6 + .../io/noties/markwon/core/CorePlugin.java | 9 + .../markwon/core/spans/TextLayoutSpan.java | 70 +++++ .../markwon/core/spans/TextViewSpan.java | 64 +++++ .../markwon/image/AsyncDrawableSpan.java | 7 +- .../java/io/noties/markwon/utils/Dip.java | 1 + .../io/noties/markwon/utils/DumpNodes.java | 1 + .../io/noties/markwon/utils/LayoutUtils.java | 72 ++++++ .../markwon/utils/LeadingMarginUtils.java | 1 - .../io/noties/markwon/utils/SpanUtils.java | 42 +++ .../markwon/ext/tables/TableRowSpan.java | 26 +- .../markwon/html/HtmlEmptyTagReplacement.java | 4 + .../io/noties/markwon/html/HtmlPlugin.java | 18 +- markwon-spans-better/build.gradle | 20 ++ markwon-spans-better/gradle.properties | 4 + .../src/main/AndroidManifest.xml | 1 + .../spans/better/BetterUnderlineSpan.java | 183 +++++++++++++ sample/src/main/AndroidManifest.xml | 7 +- .../basicplugins/BasicPluginsActivity.java | 21 ++ .../sample/basicplugins/CodeTextView.java | 192 ++++++++++++++ .../markwon/sample/editor/EditorActivity.java | 20 +- .../sample/editor/MarkdownNewLine.java | 129 ++++++++++ .../sample/html/ElegantUnderlineSpan.java | 242 ++++++++++++++++++ .../markwon/sample/html/HtmlActivity.java | 94 ++++++- .../html/HtmlElegantUnderlineTagHandler.java | 38 +++ .../sample/html/HtmlFontTagHandler.java | 42 +++ .../markwon/sample/html/IFrameHtmlPlugin.java | 48 ++++ .../inlineparser/InlineParserActivity.java | 73 +++++- settings.gradle | 1 + 29 files changed, 1421 insertions(+), 15 deletions(-) create mode 100644 markwon-core/src/main/java/io/noties/markwon/core/spans/TextLayoutSpan.java create mode 100644 markwon-core/src/main/java/io/noties/markwon/core/spans/TextViewSpan.java create mode 100644 markwon-core/src/main/java/io/noties/markwon/utils/LayoutUtils.java create mode 100644 markwon-core/src/main/java/io/noties/markwon/utils/SpanUtils.java create mode 100644 markwon-spans-better/build.gradle create mode 100644 markwon-spans-better/gradle.properties create mode 100644 markwon-spans-better/src/main/AndroidManifest.xml create mode 100644 markwon-spans-better/src/main/java/io/noties/markwon/spans/better/BetterUnderlineSpan.java create mode 100644 sample/src/main/java/io/noties/markwon/sample/basicplugins/CodeTextView.java create mode 100644 sample/src/main/java/io/noties/markwon/sample/editor/MarkdownNewLine.java create mode 100644 sample/src/main/java/io/noties/markwon/sample/html/ElegantUnderlineSpan.java create mode 100644 sample/src/main/java/io/noties/markwon/sample/html/HtmlElegantUnderlineTagHandler.java create mode 100644 sample/src/main/java/io/noties/markwon/sample/html/HtmlFontTagHandler.java create mode 100644 sample/src/main/java/io/noties/markwon/sample/html/IFrameHtmlPlugin.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 696a88d6..dff5b68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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])
Thanks [@francescocervone] diff --git a/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java b/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java index 29c63a2a..941ebacd 100644 --- a/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java +++ b/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java @@ -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 diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/TextLayoutSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextLayoutSpan.java new file mode 100644 index 00000000..679bd58c --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextLayoutSpan.java @@ -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 reference; + + @SuppressWarnings("WeakerAccess") + TextLayoutSpan(@NonNull Layout layout) { + this.reference = new WeakReference<>(layout); + } + + @Nullable + public Layout layout() { + return reference.get(); + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/TextViewSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextViewSpan.java new file mode 100644 index 00000000..3e527a9f --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextViewSpan.java @@ -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 reference; + + public TextViewSpan(@NonNull TextView textView) { + this.reference = new WeakReference<>(textView); + } + + @Nullable + public TextView textView() { + return reference.get(); + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java b/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java index 915adf8d..fe34b4c9 100644 --- a/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java +++ b/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java @@ -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; diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java b/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java index 8b579356..fe9d48dc 100644 --- a/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java +++ b/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java @@ -18,6 +18,7 @@ public class Dip { private final float density; + @SuppressWarnings("WeakerAccess") public Dip(float density) { this.density = density; } diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java b/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java index ffa2bf86..474021e7 100644 --- a/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java +++ b/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java @@ -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 { diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/LayoutUtils.java b/markwon-core/src/main/java/io/noties/markwon/utils/LayoutUtils.java new file mode 100644 index 00000000..d1c4cb77 --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/utils/LayoutUtils.java @@ -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() { + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java b/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java index 072d2ccf..601ad470 100644 --- a/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java +++ b/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java @@ -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; } diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/SpanUtils.java b/markwon-core/src/main/java/io/noties/markwon/utils/SpanUtils.java new file mode 100644 index 00000000..96396add --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/utils/SpanUtils.java @@ -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(); + } +} diff --git a/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java b/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java index 0248c4ef..32abef40 100644 --- a/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java +++ b/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java @@ -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); } } diff --git a/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java b/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java index 48b8acb4..8ad5f05a 100644 --- a/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java +++ b/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java @@ -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; } diff --git a/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java b/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java index c2c13c12..5e974ea0 100644 --- a/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java +++ b/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java @@ -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(); } diff --git a/markwon-spans-better/build.gradle b/markwon-spans-better/build.gradle new file mode 100644 index 00000000..5272858b --- /dev/null +++ b/markwon-spans-better/build.gradle @@ -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) diff --git a/markwon-spans-better/gradle.properties b/markwon-spans-better/gradle.properties new file mode 100644 index 00000000..4d3a5f47 --- /dev/null +++ b/markwon-spans-better/gradle.properties @@ -0,0 +1,4 @@ +POM_NAME=Spans Better +POM_ARTIFACT_ID=spans-better +POM_DESCRIPTION=Better spans +POM_PACKAGING=aar \ No newline at end of file diff --git a/markwon-spans-better/src/main/AndroidManifest.xml b/markwon-spans-better/src/main/AndroidManifest.xml new file mode 100644 index 00000000..92bb979a --- /dev/null +++ b/markwon-spans-better/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/markwon-spans-better/src/main/java/io/noties/markwon/spans/better/BetterUnderlineSpan.java b/markwon-spans-better/src/main/java/io/noties/markwon/spans/better/BetterUnderlineSpan.java new file mode 100644 index 00000000..2de53b78 --- /dev/null +++ b/markwon-spans-better/src/main/java/io/noties/markwon/spans/better/BetterUnderlineSpan.java @@ -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); + } +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index cda45b5d..190d2c05 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -24,7 +24,11 @@ - + + + @@ -32,6 +36,7 @@ diff --git a/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java b/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java index 9b9ffdde..02d3308a 100644 --- a/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java @@ -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); +// } } diff --git a/sample/src/main/java/io/noties/markwon/sample/basicplugins/CodeTextView.java b/sample/src/main/java/io/noties/markwon/sample/basicplugins/CodeTextView.java new file mode 100644 index 00000000..fe795b63 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/basicplugins/CodeTextView.java @@ -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); + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java index 31a4370e..84a4fdb7 100644 --- a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java @@ -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( diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/MarkdownNewLine.java b/sample/src/main/java/io/noties/markwon/sample/editor/MarkdownNewLine.java new file mode 100644 index 00000000..9552f2ba --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/editor/MarkdownNewLine.java @@ -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); + } + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/html/ElegantUnderlineSpan.java b/sample/src/main/java/io/noties/markwon/sample/html/ElegantUnderlineSpan.java new file mode 100644 index 00000000..307ea834 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/ElegantUnderlineSpan.java @@ -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) + *

+ * Failed attempt to create elegant underline as a span + *

    + *
  • in a `TextView` span is rendered, but `draw` method is invoked constantly which put pressure on CPU and memory + *
  • 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) + *
  • 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) + *
+ */ +// 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; + } +} + diff --git a/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java b/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java index db5ca541..ca18d6c6 100644 --- a/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java @@ -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 Gogogo, quite **perfect** yeah and nice and elegant"; + + final String md = "" + + underline + "\n\n" + + "" + underline + "\n\n" + + "" + underline + "\n\n" + + "" + underline + underline + underline + "\n\n" + + "" + underline + "\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" + + "

\"JUMP

\n" + + "

 

\n" + + "

Switch owners will soon get to take part in the ultimate Shonen Jump rumble. Bandai Namco announced plans to bring Jump Force to Switch as Jump Force Deluxe Edition, 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 Character Pass 2 is also in the works for all versions, starting with Shoto Todoroki from My Hero Academia.

\n" + + "

 

\n" + + "

Other than Todoroki, Bandai Namco hinted that the four other Character Pass 2 characters will hail from Hunter x Hunter, Yu Yu Hakusho, Bleach, and JoJo's Bizarre Adventure. Character Pass 2 will be priced at $17.99, and Todoroki launches this spring. 

\n" + + "

 

\n" + + "

\n" + + "

 

\n" + + "

Character Pass 2 promo:

\n" + + "

 

\n" + + "

\n" + + "

 

\n" + + "

\"\"

\n" + + "

 

\n" + + "

-------

\n" + + "

Joseph Luster is the Games and Web editor at Otaku USA Magazine. You can read his webcomic, BIG DUMB FIGHTING IDIOTS at subhumanzoids. Follow him on Twitter @Moldilox. 

"; + + 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 = "" + + " the `` 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); + } } diff --git a/sample/src/main/java/io/noties/markwon/sample/html/HtmlElegantUnderlineTagHandler.java b/sample/src/main/java/io/noties/markwon/sample/html/HtmlElegantUnderlineTagHandler.java new file mode 100644 index 00000000..76a2e5fb --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/HtmlElegantUnderlineTagHandler.java @@ -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 supportedTags() { + return Collections.singleton("u"); + } +} \ No newline at end of file diff --git a/sample/src/main/java/io/noties/markwon/sample/html/HtmlFontTagHandler.java b/sample/src/main/java/io/noties/markwon/sample/html/HtmlFontTagHandler.java new file mode 100644 index 00000000..477cbd59 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/HtmlFontTagHandler.java @@ -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 supportedTags() { + return Collections.singleton("font"); + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/html/IFrameHtmlPlugin.java b/sample/src/main/java/io/noties/markwon/sample/html/IFrameHtmlPlugin.java new file mode 100644 index 00000000..ddbbfce7 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/IFrameHtmlPlugin.java @@ -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 supportedTags() { + return Collections.singleton("iframe"); + } + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java b/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java index 4e7c87da..718d40e7 100644 --- a/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java @@ -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 disabled\n\n" + + "emphasis strong\n\n" + + "

paragraph

\n\n" + + "\n\n" + + ""; + + 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 disabled\n\n" + + "emphasis strong\n\n" + + "

paragraph

\n\n" + + "\n\n" + + ""; + + 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); + } } diff --git a/settings.gradle b/settings.gradle index 8bf10dcb..4d91087c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,5 +16,6 @@ include ':app', ':sample', ':markwon-recycler', ':markwon-recycler-table', ':markwon-simple-ext', + ':markwon-spans-better', ':markwon-syntax-highlight', ':markwon-test-span'