Sample handling of details HTML tag
This commit is contained in:
parent
17756a1137
commit
2e7d0aa46b
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
# 4.2.1-SNAPSHOT
|
||||
* Fix SpannableBuilder `subSequence` method
|
||||
* Introduce Nougat check in `BulletListItemSpan` to position bullet (for bullets to be
|
||||
positioned correctly when nested inside other `LeadingMarginSpan`s)
|
||||
|
||||
# 4.2.0
|
||||
* `MarkwonEditor` to highlight markdown input whilst editing (new module: `markwon-editor`)
|
||||
* `CoilImagesPlugin` image loader based on [Coil] library (new module: `markwon-image-coil`) ([#166], [#174])
|
||||
|
@ -187,7 +187,7 @@ public class SpannableBuilder implements Appendable, CharSequence {
|
||||
// if a span was fully including resulting subSequence it's start and
|
||||
// end must be within 0..length bounds
|
||||
s = Math.max(0, span.start - start);
|
||||
e = Math.max(length, s + (span.end - span.start));
|
||||
e = Math.min(length, s + (span.end - span.start));
|
||||
|
||||
builder.setSpan(
|
||||
span.what,
|
||||
|
@ -4,6 +4,7 @@ import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Build;
|
||||
import android.text.Layout;
|
||||
import android.text.style.LeadingMarginSpan;
|
||||
|
||||
@ -15,6 +16,13 @@ import io.noties.markwon.utils.LeadingMarginUtils;
|
||||
|
||||
public class BulletListItemSpan implements LeadingMarginSpan {
|
||||
|
||||
private static final boolean IS_NOUGAT;
|
||||
|
||||
static {
|
||||
final int sdk = Build.VERSION.SDK_INT;
|
||||
IS_NOUGAT = Build.VERSION_CODES.N == sdk || Build.VERSION_CODES.N_MR1 == sdk;
|
||||
}
|
||||
|
||||
private MarkwonTheme theme;
|
||||
|
||||
private final Paint paint = ObjectsPool.paint();
|
||||
@ -62,28 +70,41 @@ public class BulletListItemSpan implements LeadingMarginSpan {
|
||||
|
||||
final int marginLeft = (width - side) / 2;
|
||||
|
||||
// @since 2.0.2
|
||||
// There is a bug in Android Nougat, when this span receives an `x` that
|
||||
// doesn't correspond to what it should be (text is placed correctly though).
|
||||
// Let's make this a general rule -> manually calculate difference between expected/actual
|
||||
// and add this difference to resulting left/right values. If everything goes well
|
||||
// we do not encounter a bug -> this `diff` value will be 0
|
||||
final int diff;
|
||||
if (dir < 0) {
|
||||
// rtl
|
||||
diff = x - (layout.getWidth() - (width * level));
|
||||
} else {
|
||||
diff = (width * level) - x;
|
||||
}
|
||||
|
||||
// in order to support RTL
|
||||
final int l;
|
||||
final int r;
|
||||
{
|
||||
final int left = x + (dir * marginLeft);
|
||||
final int right = left + (dir * side);
|
||||
l = Math.min(left, right) + (dir * diff);
|
||||
r = Math.max(left, right) + (dir * diff);
|
||||
// @since 4.2.1-SNAPSHOT to correctly position bullet
|
||||
// when nested inside other LeadingMarginSpans (sorry, Nougat)
|
||||
if (IS_NOUGAT) {
|
||||
|
||||
// @since 2.0.2
|
||||
// There is a bug in Android Nougat, when this span receives an `x` that
|
||||
// doesn't correspond to what it should be (text is placed correctly though).
|
||||
// Let's make this a general rule -> manually calculate difference between expected/actual
|
||||
// and add this difference to resulting left/right values. If everything goes well
|
||||
// we do not encounter a bug -> this `diff` value will be 0
|
||||
final int diff;
|
||||
if (dir < 0) {
|
||||
// rtl
|
||||
diff = x - (layout.getWidth() - (width * level));
|
||||
} else {
|
||||
diff = (width * level) - x;
|
||||
}
|
||||
|
||||
final int left = x + (dir * marginLeft);
|
||||
final int right = left + (dir * side);
|
||||
l = Math.min(left, right) + (dir * diff);
|
||||
r = Math.max(left, right) + (dir * diff);
|
||||
|
||||
} else {
|
||||
if (dir > 0) {
|
||||
l = x + marginLeft;
|
||||
} else {
|
||||
l = x - width + marginLeft;
|
||||
}
|
||||
r = l + side;
|
||||
}
|
||||
}
|
||||
|
||||
final int t = baseline + (int) ((paint.descent() + paint.ascent()) / 2.F + .5F) - (side / 2);
|
||||
|
@ -34,6 +34,7 @@
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity android:name=".inlineparser.InlineParserActivity" />
|
||||
<activity android:name=".htmldetails.HtmlDetailsActivity" />
|
||||
|
||||
</application>
|
||||
|
||||
|
@ -24,6 +24,7 @@ import io.noties.markwon.sample.customextension.CustomExtensionActivity;
|
||||
import io.noties.markwon.sample.customextension2.CustomExtensionActivity2;
|
||||
import io.noties.markwon.sample.editor.EditorActivity;
|
||||
import io.noties.markwon.sample.html.HtmlActivity;
|
||||
import io.noties.markwon.sample.htmldetails.HtmlDetailsActivity;
|
||||
import io.noties.markwon.sample.inlineparser.InlineParserActivity;
|
||||
import io.noties.markwon.sample.latex.LatexActivity;
|
||||
import io.noties.markwon.sample.precomputed.PrecomputedActivity;
|
||||
@ -127,6 +128,10 @@ public class MainActivity extends Activity {
|
||||
activity = InlineParserActivity.class;
|
||||
break;
|
||||
|
||||
case HTML_DETAILS:
|
||||
activity = HtmlDetailsActivity.class;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new IllegalStateException("No Activity is associated with sample-item: " + item);
|
||||
}
|
||||
|
@ -25,7 +25,9 @@ public enum Sample {
|
||||
|
||||
EDITOR(R.string.sample_editor),
|
||||
|
||||
INLINE_PARSER(R.string.sample_inline_parser);
|
||||
INLINE_PARSER(R.string.sample_inline_parser),
|
||||
|
||||
HTML_DETAILS(R.string.sample_html_details);
|
||||
|
||||
private final int textResId;
|
||||
|
||||
|
@ -0,0 +1,410 @@
|
||||
package io.noties.markwon.sample.htmldetails;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Bundle;
|
||||
import android.text.Layout;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.text.style.LeadingMarginSpan;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
import io.noties.markwon.Markwon;
|
||||
import io.noties.markwon.MarkwonVisitor;
|
||||
import io.noties.markwon.SpannableBuilder;
|
||||
import io.noties.markwon.core.MarkwonTheme;
|
||||
import io.noties.markwon.html.HtmlPlugin;
|
||||
import io.noties.markwon.html.HtmlTag;
|
||||
import io.noties.markwon.html.MarkwonHtmlRenderer;
|
||||
import io.noties.markwon.html.TagHandler;
|
||||
import io.noties.markwon.image.ImagesPlugin;
|
||||
import io.noties.markwon.sample.R;
|
||||
import io.noties.markwon.utils.LeadingMarginUtils;
|
||||
import io.noties.markwon.utils.NoCopySpannableFactory;
|
||||
|
||||
public class HtmlDetailsActivity extends Activity {
|
||||
|
||||
private ViewGroup content;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setContentView(R.layout.activity_html_details);
|
||||
|
||||
content = findViewById(R.id.content);
|
||||
|
||||
sample_details();
|
||||
}
|
||||
|
||||
private void sample_details() {
|
||||
|
||||
final String md = "# Hello\n\n<details>\n" +
|
||||
" <summary>stuff with \n\n*mark* **down**\n\n</summary>\n" +
|
||||
" <p>\n\n" +
|
||||
"<!-- the above p cannot start right at the beginning of the line and is mandatory for everything else to work -->\n" +
|
||||
"## *formatted* **heading** with [a](link)\n" +
|
||||
"```java\n" +
|
||||
"code block\n" +
|
||||
"```\n" +
|
||||
"\n" +
|
||||
" <details>\n" +
|
||||
" <summary><small>nested</small> stuff</summary><p>\n" +
|
||||
"<!-- alternative placement of p shown above -->\n" +
|
||||
"\n" +
|
||||
"* list\n" +
|
||||
"* with\n" +
|
||||
"\n\n" +
|
||||
"\n\n" +
|
||||
" 1. nested\n" +
|
||||
" 1. items\n" +
|
||||
"\n" +
|
||||
" ```java\n" +
|
||||
" // including code\n" +
|
||||
" ```\n" +
|
||||
" 1. blocks\n" +
|
||||
"\n" +
|
||||
"<details><summary>The 3rd!</summary>\n\n" +
|
||||
"**bold** _em_\n</details>" +
|
||||
" </p></details>\n" +
|
||||
"</p></details>\n\n" +
|
||||
"and **this** *is* how...";
|
||||
|
||||
final Markwon markwon = Markwon.builder(this)
|
||||
.usePlugin(HtmlPlugin.create(plugin ->
|
||||
plugin.addHandler(new DetailsTagHandler())))
|
||||
.usePlugin(ImagesPlugin.create())
|
||||
.build();
|
||||
|
||||
final Spanned spanned = markwon.toMarkdown(md);
|
||||
final DetailsParsingSpan[] spans = spanned.getSpans(0, spanned.length(), DetailsParsingSpan.class);
|
||||
|
||||
// if we have no details, proceed as usual (single text-view)
|
||||
if (spans == null || spans.length == 0) {
|
||||
// no details
|
||||
final TextView textView = appendTextView();
|
||||
markwon.setParsedMarkdown(textView, spanned);
|
||||
return;
|
||||
}
|
||||
|
||||
final List<DetailsElement> list = new ArrayList<>();
|
||||
|
||||
for (DetailsParsingSpan span : spans) {
|
||||
final DetailsElement e = settle(new DetailsElement(spanned.getSpanStart(span), spanned.getSpanEnd(span), span.summary), list);
|
||||
if (e != null) {
|
||||
list.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
for (DetailsElement element : list) {
|
||||
initDetails(element, spanned);
|
||||
}
|
||||
|
||||
sort(list);
|
||||
|
||||
|
||||
TextView textView;
|
||||
int start = 0;
|
||||
|
||||
for (DetailsElement element : list) {
|
||||
|
||||
if (element.start != start) {
|
||||
// subSequence and add new TextView
|
||||
textView = appendTextView();
|
||||
textView.setText(subSequenceTrimmed(spanned, start, element.start));
|
||||
}
|
||||
|
||||
// now add details TextView
|
||||
textView = appendTextView();
|
||||
initDetailsTextView(markwon, textView, element);
|
||||
|
||||
start = element.end;
|
||||
}
|
||||
|
||||
if (start != spanned.length()) {
|
||||
// another textView with rest content
|
||||
textView = appendTextView();
|
||||
textView.setText(subSequenceTrimmed(spanned, start, spanned.length()));
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private TextView appendTextView() {
|
||||
final View view = getLayoutInflater().inflate(R.layout.view_html_details_text_view, content, false);
|
||||
final TextView textView = view.findViewById(R.id.text);
|
||||
content.addView(view);
|
||||
return textView;
|
||||
}
|
||||
|
||||
private void initDetailsTextView(
|
||||
@NonNull Markwon markwon,
|
||||
@NonNull TextView textView,
|
||||
@NonNull DetailsElement element) {
|
||||
|
||||
// minor optimization
|
||||
textView.setSpannableFactory(NoCopySpannableFactory.getInstance());
|
||||
|
||||
// so, each element with children is a details tag
|
||||
// there is a reason why we needed the SpannableBuilder in the first place -> we must revert spans
|
||||
// final SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
final SpannableBuilder builder = new SpannableBuilder();
|
||||
append(builder, markwon, textView, element, element);
|
||||
markwon.setParsedMarkdown(textView, builder.spannableStringBuilder());
|
||||
}
|
||||
|
||||
private void append(
|
||||
@NonNull SpannableBuilder builder,
|
||||
@NonNull Markwon markwon,
|
||||
@NonNull TextView textView,
|
||||
@NonNull DetailsElement root,
|
||||
@NonNull DetailsElement element) {
|
||||
if (!element.children.isEmpty()) {
|
||||
|
||||
final int start = builder.length();
|
||||
|
||||
// builder.append(element.content);
|
||||
builder.append(subSequenceTrimmed(element.content, 0, element.content.length()));
|
||||
|
||||
builder.setSpan(new ClickableSpan() {
|
||||
@Override
|
||||
public void onClick(@NonNull View widget) {
|
||||
element.expanded = !element.expanded;
|
||||
|
||||
initDetailsTextView(markwon, textView, root);
|
||||
}
|
||||
}, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
if (element.expanded) {
|
||||
for (DetailsElement child : element.children) {
|
||||
append(builder, markwon, textView, root, child);
|
||||
}
|
||||
}
|
||||
|
||||
builder.setSpan(new DetailsSpan(markwon.configuration().theme(), element), start);
|
||||
|
||||
} else {
|
||||
builder.append(element.content);
|
||||
}
|
||||
}
|
||||
|
||||
// if null -> remove from where it was processed,
|
||||
// else replace from where it was processed with a new one (can become expandable)
|
||||
@Nullable
|
||||
private static DetailsElement settle(
|
||||
@NonNull DetailsElement element,
|
||||
@NonNull List<? extends DetailsElement> elements) {
|
||||
for (DetailsElement e : elements) {
|
||||
if (element.start > e.start && element.end <= e.end) {
|
||||
final DetailsElement settled = settle(element, e.children);
|
||||
if (settled != null) {
|
||||
|
||||
// the thing is we must balance children if done like this
|
||||
// let's just create a tree actually, so we are easier to modify
|
||||
final Iterator<DetailsElement> iterator = e.children.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
final DetailsElement balanced = settle(iterator.next(), Collections.singletonList(element));
|
||||
if (balanced == null) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// add to our children
|
||||
e.children.add(element);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
private static void initDetails(@NonNull DetailsElement element, @NonNull Spanned spanned) {
|
||||
int end = element.end;
|
||||
for (int i = element.children.size() - 1; i >= 0; i--) {
|
||||
final DetailsElement child = element.children.get(i);
|
||||
if (child.end < end) {
|
||||
element.children.add(new DetailsElement(child.end, end, spanned.subSequence(child.end, end)));
|
||||
}
|
||||
initDetails(child, spanned);
|
||||
end = child.start;
|
||||
}
|
||||
|
||||
final int start = (element.start + element.content.length());
|
||||
if (end != start) {
|
||||
element.children.add(new DetailsElement(start, end, spanned.subSequence(start, end)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void sort(@NonNull List<DetailsElement> elements) {
|
||||
Collections.sort(elements, (o1, o2) -> Integer.compare(o1.start, o2.start));
|
||||
for (DetailsElement element : elements) {
|
||||
sort(element.children);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static CharSequence subSequenceTrimmed(@NonNull CharSequence cs, int start, int end) {
|
||||
|
||||
while (start < end) {
|
||||
|
||||
final boolean isStartEmpty = Character.isWhitespace(cs.charAt(start));
|
||||
final boolean isEndEmpty = Character.isWhitespace(cs.charAt(end - 1));
|
||||
|
||||
if (!isStartEmpty && !isEndEmpty) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isStartEmpty) {
|
||||
start += 1;
|
||||
}
|
||||
if (isEndEmpty) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return cs.subSequence(start, end);
|
||||
}
|
||||
|
||||
private static class DetailsElement {
|
||||
|
||||
final int start;
|
||||
final int end;
|
||||
final CharSequence content;
|
||||
final List<DetailsElement> children = new ArrayList<>(0);
|
||||
|
||||
boolean expanded;
|
||||
|
||||
DetailsElement(int start, int end, @NonNull CharSequence content) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DetailsElement{" +
|
||||
"start=" + start +
|
||||
", end=" + end +
|
||||
", content=" + toStringContent(content) +
|
||||
", children=" + children +
|
||||
", expanded=" + expanded +
|
||||
'}';
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String toStringContent(@NonNull CharSequence cs) {
|
||||
return cs.toString().replaceAll("\n", "\\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static class DetailsTagHandler extends TagHandler {
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
@NonNull MarkwonVisitor visitor,
|
||||
@NonNull MarkwonHtmlRenderer renderer,
|
||||
@NonNull HtmlTag tag) {
|
||||
|
||||
int summaryEnd = -1;
|
||||
|
||||
for (HtmlTag child : tag.getAsBlock().children()) {
|
||||
|
||||
if (!child.isClosed()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("summary".equals(child.name())) {
|
||||
summaryEnd = child.end();
|
||||
}
|
||||
|
||||
final TagHandler tagHandler = renderer.tagHandler(child.name());
|
||||
if (tagHandler != null) {
|
||||
tagHandler.handle(visitor, renderer, child);
|
||||
} else if (child.isBlock()) {
|
||||
visitChildren(visitor, renderer, child.getAsBlock());
|
||||
}
|
||||
}
|
||||
|
||||
if (summaryEnd > -1) {
|
||||
visitor.builder().setSpan(new DetailsParsingSpan(
|
||||
subSequenceTrimmed(visitor.builder(), tag.start(), summaryEnd)
|
||||
), tag.start(), tag.end());
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Collection<String> supportedTags() {
|
||||
return Collections.singleton("details");
|
||||
}
|
||||
}
|
||||
|
||||
private static class DetailsParsingSpan {
|
||||
|
||||
final CharSequence summary;
|
||||
|
||||
DetailsParsingSpan(@NonNull CharSequence summary) {
|
||||
this.summary = summary;
|
||||
}
|
||||
}
|
||||
|
||||
private static class DetailsSpan implements LeadingMarginSpan {
|
||||
|
||||
private final DetailsElement element;
|
||||
private final int blockMargin;
|
||||
private final int blockQuoteWidth;
|
||||
|
||||
private final Rect rect = new Rect();
|
||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
DetailsSpan(@NonNull MarkwonTheme theme, @NonNull DetailsElement element) {
|
||||
this.element = element;
|
||||
this.blockMargin = theme.getBlockMargin();
|
||||
this.blockQuoteWidth = theme.getBlockQuoteWidth();
|
||||
this.paint.setStyle(Paint.Style.FILL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLeadingMargin(boolean first) {
|
||||
return blockMargin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, CharSequence text, int start, int end, boolean first, Layout layout) {
|
||||
|
||||
if (LeadingMarginUtils.selfStart(start, text, this)) {
|
||||
rect.set(x, top, x + blockMargin, bottom);
|
||||
if (element.expanded) {
|
||||
paint.setColor(Color.GREEN);
|
||||
} else {
|
||||
paint.setColor(Color.RED);
|
||||
}
|
||||
paint.setStyle(Paint.Style.FILL);
|
||||
c.drawRect(rect, paint);
|
||||
|
||||
} else {
|
||||
|
||||
if (element.expanded) {
|
||||
final int l = (blockMargin - blockQuoteWidth) / 2;
|
||||
rect.set(x + l, top, x + l + blockQuoteWidth, bottom);
|
||||
paint.setStyle(Paint.Style.FILL);
|
||||
paint.setColor(Color.GRAY);
|
||||
c.drawRect(rect, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
12
sample/src/main/res/layout/activity_html_details.xml
Normal file
12
sample/src/main/res/layout/activity_html_details.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
|
||||
</ScrollView>
|
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dip"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="#000" />
|
@ -29,4 +29,6 @@
|
||||
|
||||
<string name="sample_inline_parser"># \# Inline Parser\n\nUsage of custom inline parser</string>
|
||||
|
||||
<string name="sample_html_details"># \# HTML <details> tag\n\n<details> tag parsed and rendered</string>
|
||||
|
||||
</resources>
|
Loading…
x
Reference in New Issue
Block a user