TextViewSpan and TextLayoutSpan

This commit is contained in:
Dimitry Ivanov 2020-04-28 16:57:43 +03:00
parent 3006f8d486
commit b497f872e5
29 changed files with 1421 additions and 15 deletions

View File

@ -1,6 +1,12 @@
# Changelog # Changelog
# $nap; # $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 # 4.3.1
* Fix DexGuard optimization issue ([#216])<br>Thanks [@francescocervone] * Fix DexGuard optimization issue ([#216])<br>Thanks [@francescocervone]

View File

@ -1,5 +1,6 @@
package io.noties.markwon.core; package io.noties.markwon.core;
import android.text.Spannable;
import android.text.Spanned; import android.text.Spanned;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.widget.TextView; 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.StrongEmphasisSpanFactory;
import io.noties.markwon.core.factory.ThematicBreakSpanFactory; import io.noties.markwon.core.factory.ThematicBreakSpanFactory;
import io.noties.markwon.core.spans.OrderedListItemSpan; import io.noties.markwon.core.spans.OrderedListItemSpan;
import io.noties.markwon.core.spans.TextViewSpan;
import io.noties.markwon.image.ImageProps; import io.noties.markwon.image.ImageProps;
/** /**
@ -150,6 +152,13 @@ public class CorePlugin extends AbstractMarkwonPlugin {
@Override @Override
public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) {
OrderedListItemSpan.measure(textView, 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 @Override

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -14,6 +14,7 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import io.noties.markwon.core.MarkwonTheme; import io.noties.markwon.core.MarkwonTheme;
import io.noties.markwon.utils.SpanUtils;
@SuppressWarnings("WeakerAccess") @SuppressWarnings("WeakerAccess")
public class AsyncDrawableSpan extends ReplacementSpan { public class AsyncDrawableSpan extends ReplacementSpan {
@ -99,7 +100,11 @@ public class AsyncDrawableSpan extends ReplacementSpan {
int bottom, int bottom,
@NonNull Paint paint) { @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; final AsyncDrawable drawable = this.drawable;

View File

@ -18,6 +18,7 @@ public class Dip {
private final float density; private final float density;
@SuppressWarnings("WeakerAccess")
public Dip(float density) { public Dip(float density) {
this.density = density; this.density = density;
} }

View File

@ -12,6 +12,7 @@ import java.lang.reflect.Method;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
// utility class to print parsed Nodes hierarchy // utility class to print parsed Nodes hierarchy
@SuppressWarnings({"unused", "WeakerAccess"})
public abstract class DumpNodes { public abstract class DumpNodes {
public interface NodeProcessor { public interface NodeProcessor {

View File

@ -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() {
}
}

View File

@ -4,7 +4,6 @@ import android.text.Spanned;
public abstract class LeadingMarginUtils { public abstract class LeadingMarginUtils {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public static boolean selfStart(int start, CharSequence text, Object span) { public static boolean selfStart(int start, CharSequence text, Object span) {
return text instanceof Spanned && ((Spanned) text).getSpanStart(span) == start; return text instanceof Spanned && ((Spanned) text).getSpanStart(span) == start;
} }

View File

@ -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();
}
}

View File

@ -5,6 +5,8 @@ import android.graphics.Canvas;
import android.graphics.Paint; import android.graphics.Paint;
import android.graphics.Rect; import android.graphics.Rect;
import android.text.Layout; import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned; import android.text.Spanned;
import android.text.StaticLayout; import android.text.StaticLayout;
import android.text.TextPaint; import android.text.TextPaint;
@ -20,7 +22,9 @@ import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import io.noties.markwon.core.spans.TextLayoutSpan;
import io.noties.markwon.utils.LeadingMarginUtils; import io.noties.markwon.utils.LeadingMarginUtils;
import io.noties.markwon.utils.SpanUtils;
public class TableRowSpan extends ReplacementSpan { public class TableRowSpan extends ReplacementSpan {
@ -144,7 +148,7 @@ public class TableRowSpan extends ReplacementSpan {
int bottom, int bottom,
@NonNull Paint p) { @NonNull Paint p) {
if (recreateLayouts(canvas.getWidth())) { if (recreateLayouts(SpanUtils.width(canvas, text))) {
width = canvas.getWidth(); width = canvas.getWidth();
// @since 4.3.1 it's important to cast to TextPaint in order to display links, etc // @since 4.3.1 it's important to cast to TextPaint in order to display links, etc
if (p instanceof TextPaint) { if (p instanceof TextPaint) {
@ -295,17 +299,31 @@ public class TableRowSpan extends ReplacementSpan {
this.layouts.clear(); this.layouts.clear();
Cell cell; Cell cell;
StaticLayout layout; StaticLayout layout;
Spannable spannable;
for (int i = 0, size = cells.size(); i < size; i++) { for (int i = 0, size = cells.size(); i < size; i++) {
cell = cells.get(i); cell = cells.get(i);
if (cell.text instanceof Spannable) {
spannable = (Spannable) cell.text;
} else {
spannable = new SpannableString(cell.text);
}
layout = new StaticLayout( layout = new StaticLayout(
cell.text, spannable,
textPaint, textPaint,
w, w,
alignment(cell.alignment), alignment(cell.alignment),
1.F, 1.0F,
.0F, 0.0F,
false false
); );
// @since $nap;
TextLayoutSpan.applyTo(spannable, layout);
layouts.add(layout); layouts.add(layout);
} }
} }

View File

@ -21,6 +21,7 @@ public class HtmlEmptyTagReplacement {
} }
private static final String IMG_REPLACEMENT = "\uFFFC"; 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 * @return replacement for supplied startTag or null if no replacement should occur (which will
@ -44,6 +45,9 @@ public class HtmlEmptyTagReplacement {
} else { } else {
replacement = alt; replacement = alt;
} }
} else if ("iframe".equals(name)) {
// @since $nap; make iframe non-empty
replacement = IFRAME_REPLACEMENT;
} else { } else {
replacement = null; replacement = null;
} }

View File

@ -53,13 +53,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin {
public static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F; public static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F;
private final MarkwonHtmlRendererImpl.Builder builder; private final MarkwonHtmlRendererImpl.Builder builder;
private final MarkwonHtmlParser htmlParser;
private MarkwonHtmlParser htmlParser;
private MarkwonHtmlRenderer htmlRenderer; private MarkwonHtmlRenderer htmlRenderer;
// @since $nap;
private HtmlEmptyTagReplacement emptyTagReplacement = new HtmlEmptyTagReplacement();
@SuppressWarnings("WeakerAccess") @SuppressWarnings("WeakerAccess")
HtmlPlugin() { HtmlPlugin() {
this.builder = new MarkwonHtmlRendererImpl.Builder(); this.builder = new MarkwonHtmlRendererImpl.Builder();
this.htmlParser = MarkwonHtmlParserImpl.create();
} }
/** /**
@ -104,6 +107,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin {
return this; return this;
} }
/**
* @param emptyTagReplacement {@link HtmlEmptyTagReplacement}
* @since $nap;
*/
@NonNull
public HtmlPlugin emptyTagReplacement(@NonNull HtmlEmptyTagReplacement emptyTagReplacement) {
this.emptyTagReplacement = emptyTagReplacement;
return this;
}
@Override @Override
public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configurationBuilder) { public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configurationBuilder) {
@ -128,6 +141,7 @@ public class HtmlPlugin extends AbstractMarkwonPlugin {
builder.addDefaultTagHandler(new HeadingHandler()); builder.addDefaultTagHandler(new HeadingHandler());
} }
htmlParser = MarkwonHtmlParserImpl.create(emptyTagReplacement);
htmlRenderer = builder.build(); htmlRenderer = builder.build();
} }

View 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)

View File

@ -0,0 +1,4 @@
POM_NAME=Spans Better
POM_ARTIFACT_ID=spans-better
POM_DESCRIPTION=Better spans
POM_PACKAGING=aar

View File

@ -0,0 +1 @@
<manifest package="io.noties.markwon.spans.better" />

View File

@ -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);
}
}

View File

@ -24,7 +24,11 @@
<activity android:name="io.noties.markwon.sample.basicplugins.BasicPluginsActivity" /> <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.recycler.RecyclerActivity" />
<activity android:name="io.noties.markwon.sample.theme.ThemeActivity" /> <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=".simpleext.SimpleExtActivity" />
<activity android:name=".customextension2.CustomExtensionActivity2" /> <activity android:name=".customextension2.CustomExtensionActivity2" />
<activity android:name=".precomputed.PrecomputedActivity" /> <activity android:name=".precomputed.PrecomputedActivity" />
@ -32,6 +36,7 @@
<activity <activity
android:name=".editor.EditorActivity" android:name=".editor.EditorActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity android:name=".inlineparser.InlineParserActivity" /> <activity android:name=".inlineparser.InlineParserActivity" />

View File

@ -434,4 +434,25 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions {
markwon.setMarkdown(textView, md); 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);
// }
} }

View File

@ -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);
}
}

View File

@ -6,6 +6,7 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextPaint; import android.text.TextPaint;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.text.style.MetricAffectingSpan; import android.text.style.MetricAffectingSpan;
@ -25,8 +26,11 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import io.noties.debug.AndroidLogDebugOutput;
import io.noties.debug.Debug;
import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.Markwon; import io.noties.markwon.Markwon;
import io.noties.markwon.SoftBreakAddsNewLinePlugin;
import io.noties.markwon.core.spans.EmphasisSpan; import io.noties.markwon.core.spans.EmphasisSpan;
import io.noties.markwon.core.spans.StrongEmphasisSpan; import io.noties.markwon.core.spans.StrongEmphasisSpan;
import io.noties.markwon.editor.AbstractEditHandler; import io.noties.markwon.editor.AbstractEditHandler;
@ -65,7 +69,8 @@ public class EditorActivity extends ActivityWithMenuOptions {
.add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin) .add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin)
.add("pluginRequire", this::plugin_require) .add("pluginRequire", this::plugin_require)
.add("pluginNoDefaults", this::plugin_no_defaults) .add("pluginNoDefaults", this::plugin_no_defaults)
.add("heading", this::heading); .add("heading", this::heading)
.add("newLine", this::newLine);
} }
@Override @Override
@ -98,7 +103,10 @@ public class EditorActivity extends ActivityWithMenuOptions {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
createView(); createView();
Debug.init(new AndroidLogDebugOutput(true));
multiple_edit_spans(); multiple_edit_spans();
// newLine();
} }
private void simple_process() { private void simple_process() {
@ -230,6 +238,7 @@ public class EditorActivity extends ActivityWithMenuOptions {
builder.inlineParserFactory(inlineParserFactory); builder.inlineParserFactory(inlineParserFactory);
} }
}) })
.usePlugin(SoftBreakAddsNewLinePlugin.create())
.build(); .build();
final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); 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)); 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() { private void plugin_require() {
// usage of plugin from other plugins // usage of plugin from other plugins
@ -295,6 +311,8 @@ public class EditorActivity extends ActivityWithMenuOptions {
}) })
.build(); .build();
editText.setMovementMethod(LinkMovementMethod.getInstance());
final MarkwonEditor editor = MarkwonEditor.create(markwon); final MarkwonEditor editor = MarkwonEditor.create(markwon);
editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender(

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -1,18 +1,18 @@
package io.noties.markwon.sample.html; package io.noties.markwon.sample.html;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.text.Layout; import android.text.Layout;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan; import android.text.style.AbsoluteSizeSpan;
import android.text.style.AlignmentSpan; import android.text.style.AlignmentSpan;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.Px; import androidx.annotation.Px;
import org.commonmark.node.Paragraph;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.Random; import java.util.Random;
@ -23,6 +23,7 @@ import io.noties.markwon.MarkwonConfiguration;
import io.noties.markwon.MarkwonVisitor; import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.RenderProps; import io.noties.markwon.RenderProps;
import io.noties.markwon.SpannableBuilder; import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.html.HtmlEmptyTagReplacement;
import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.html.HtmlPlugin;
import io.noties.markwon.html.HtmlTag; import io.noties.markwon.html.HtmlTag;
import io.noties.markwon.html.MarkwonHtmlRenderer; import io.noties.markwon.html.MarkwonHtmlRenderer;
@ -42,7 +43,10 @@ public class HtmlActivity extends ActivityWithMenuOptions {
.add("align", this::align) .add("align", this::align)
.add("randomCharSize", this::randomCharSize) .add("randomCharSize", this::randomCharSize)
.add("enhance", this::enhance) .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; private TextView textView;
@ -57,7 +61,7 @@ public class HtmlActivity extends ActivityWithMenuOptions {
textView = findViewById(R.id.text_view); textView = findViewById(R.id.text_view);
align(); elegantUnderline();
} }
// we can use `SimpleTagHandler` for _simple_ cases (when the whole tag content // 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); 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\">&nbsp;</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&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;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\">&nbsp;</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&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;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&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;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&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;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\">&nbsp;</span></p>\n" +
"<p class=\"p2\">&nbsp;</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\">&nbsp;</p>\n" +
"<p class=\"p1\">Character Pass 2 promo:</p>\n" +
"<p class=\"p2\">&nbsp;</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\">&nbsp;</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\">&nbsp;</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\">&nbsp;</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);
}
} }

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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");
}
}
}

View File

@ -6,10 +6,14 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; 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.Block;
import org.commonmark.node.BlockQuote; import org.commonmark.node.BlockQuote;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading; import org.commonmark.node.Heading;
import org.commonmark.node.HtmlBlock; import org.commonmark.node.HtmlBlock;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.ListBlock; import org.commonmark.node.ListBlock;
import org.commonmark.node.ThematicBreak; import org.commonmark.node.ThematicBreak;
import org.commonmark.parser.InlineParserFactory; import org.commonmark.parser.InlineParserFactory;
@ -22,7 +26,9 @@ import java.util.Set;
import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.Markwon; import io.noties.markwon.Markwon;
import io.noties.markwon.inlineparser.BackticksInlineProcessor; import io.noties.markwon.inlineparser.BackticksInlineProcessor;
import io.noties.markwon.inlineparser.BangInlineProcessor;
import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; import io.noties.markwon.inlineparser.CloseBracketInlineProcessor;
import io.noties.markwon.inlineparser.HtmlInlineProcessor;
import io.noties.markwon.inlineparser.MarkwonInlineParser; import io.noties.markwon.inlineparser.MarkwonInlineParser;
import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin;
import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; import io.noties.markwon.inlineparser.OpenBracketInlineProcessor;
@ -41,7 +47,9 @@ public class InlineParserActivity extends ActivityWithMenuOptions {
.add("links_only", this::links_only) .add("links_only", this::links_only)
.add("disable_code", this::disable_code) .add("disable_code", this::disable_code)
.add("pluginWithDefaults", this::pluginWithDefaults) .add("pluginWithDefaults", this::pluginWithDefaults)
.add("pluginNoDefaults", this::pluginNoDefaults); .add("pluginNoDefaults", this::pluginNoDefaults)
.add("disableHtmlInlineParser", this::disableHtmlInlineParser)
.add("disableHtmlSanitize", this::disableHtmlSanitize);
} }
@Override @Override
@ -173,4 +181,67 @@ public class InlineParserActivity extends ActivityWithMenuOptions {
markwon.setMarkdown(textView, md); 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("<", "&lt;")
.replaceAll(">", "&gt;");
}
})
.build();
markwon.setMarkdown(textView, md);
}
} }

View File

@ -16,5 +16,6 @@ include ':app', ':sample',
':markwon-recycler', ':markwon-recycler',
':markwon-recycler-table', ':markwon-recycler-table',
':markwon-simple-ext', ':markwon-simple-ext',
':markwon-spans-better',
':markwon-syntax-highlight', ':markwon-syntax-highlight',
':markwon-test-span' ':markwon-test-span'