Sample handling of details HTML tag

This commit is contained in:
Dimitry Ivanov 2019-12-23 17:31:27 +03:00
parent 17756a1137
commit 2e7d0aa46b
10 changed files with 486 additions and 20 deletions

View File

@ -1,5 +1,10 @@
# Changelog # 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 # 4.2.0
* `MarkwonEditor` to highlight markdown input whilst editing (new module: `markwon-editor`) * `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]) * `CoilImagesPlugin` image loader based on [Coil] library (new module: `markwon-image-coil`) ([#166], [#174])

View File

@ -187,7 +187,7 @@ public class SpannableBuilder implements Appendable, CharSequence {
// if a span was fully including resulting subSequence it's start and // if a span was fully including resulting subSequence it's start and
// end must be within 0..length bounds // end must be within 0..length bounds
s = Math.max(0, span.start - start); 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( builder.setSpan(
span.what, span.what,

View File

@ -4,6 +4,7 @@ import android.graphics.Canvas;
import android.graphics.Paint; import android.graphics.Paint;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.RectF; import android.graphics.RectF;
import android.os.Build;
import android.text.Layout; import android.text.Layout;
import android.text.style.LeadingMarginSpan; import android.text.style.LeadingMarginSpan;
@ -15,6 +16,13 @@ import io.noties.markwon.utils.LeadingMarginUtils;
public class BulletListItemSpan implements LeadingMarginSpan { 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 MarkwonTheme theme;
private final Paint paint = ObjectsPool.paint(); private final Paint paint = ObjectsPool.paint();
@ -62,28 +70,41 @@ public class BulletListItemSpan implements LeadingMarginSpan {
final int marginLeft = (width - side) / 2; 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 // in order to support RTL
final int l; final int l;
final int r; final int r;
{ {
final int left = x + (dir * marginLeft); // @since 4.2.1-SNAPSHOT to correctly position bullet
final int right = left + (dir * side); // when nested inside other LeadingMarginSpans (sorry, Nougat)
l = Math.min(left, right) + (dir * diff); if (IS_NOUGAT) {
r = Math.max(left, right) + (dir * diff);
// @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); final int t = baseline + (int) ((paint.descent() + paint.ascent()) / 2.F + .5F) - (side / 2);

View File

@ -34,6 +34,7 @@
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity android:name=".inlineparser.InlineParserActivity" /> <activity android:name=".inlineparser.InlineParserActivity" />
<activity android:name=".htmldetails.HtmlDetailsActivity" />
</application> </application>

View File

@ -24,6 +24,7 @@ import io.noties.markwon.sample.customextension.CustomExtensionActivity;
import io.noties.markwon.sample.customextension2.CustomExtensionActivity2; import io.noties.markwon.sample.customextension2.CustomExtensionActivity2;
import io.noties.markwon.sample.editor.EditorActivity; import io.noties.markwon.sample.editor.EditorActivity;
import io.noties.markwon.sample.html.HtmlActivity; 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.inlineparser.InlineParserActivity;
import io.noties.markwon.sample.latex.LatexActivity; import io.noties.markwon.sample.latex.LatexActivity;
import io.noties.markwon.sample.precomputed.PrecomputedActivity; import io.noties.markwon.sample.precomputed.PrecomputedActivity;
@ -127,6 +128,10 @@ public class MainActivity extends Activity {
activity = InlineParserActivity.class; activity = InlineParserActivity.class;
break; break;
case HTML_DETAILS:
activity = HtmlDetailsActivity.class;
break;
default: default:
throw new IllegalStateException("No Activity is associated with sample-item: " + item); throw new IllegalStateException("No Activity is associated with sample-item: " + item);
} }

View File

@ -25,7 +25,9 @@ public enum Sample {
EDITOR(R.string.sample_editor), 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; private final int textResId;

View File

@ -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" +
"![img](https://raw.githubusercontent.com/noties/Markwon/master/art/markwon_logo.png)\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);
}
}
}
}
}

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

View File

@ -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" />

View File

@ -29,4 +29,6 @@
<string name="sample_inline_parser"># \# Inline Parser\n\nUsage of custom inline parser</string> <string name="sample_inline_parser"># \# Inline Parser\n\nUsage of custom inline parser</string>
<string name="sample_html_details"># \# HTML &lt;details> tag\n\n&lt;details> tag parsed and rendered</string>
</resources> </resources>