diff --git a/CHANGELOG.md b/CHANGELOG.md index 1910f583..2445fff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]) diff --git a/markwon-core/src/main/java/io/noties/markwon/SpannableBuilder.java b/markwon-core/src/main/java/io/noties/markwon/SpannableBuilder.java index 6a15a9a8..8d4568d2 100644 --- a/markwon-core/src/main/java/io/noties/markwon/SpannableBuilder.java +++ b/markwon-core/src/main/java/io/noties/markwon/SpannableBuilder.java @@ -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, diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/BulletListItemSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/BulletListItemSpan.java index 6d8da746..fa366b53 100644 --- a/markwon-core/src/main/java/io/noties/markwon/core/spans/BulletListItemSpan.java +++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/BulletListItemSpan.java @@ -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); diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index ee887f8c..5e0ae714 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ android:windowSoftInputMode="adjustResize" /> + diff --git a/sample/src/main/java/io/noties/markwon/sample/MainActivity.java b/sample/src/main/java/io/noties/markwon/sample/MainActivity.java index db937d19..4cdd6d73 100644 --- a/sample/src/main/java/io/noties/markwon/sample/MainActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/MainActivity.java @@ -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); } diff --git a/sample/src/main/java/io/noties/markwon/sample/Sample.java b/sample/src/main/java/io/noties/markwon/sample/Sample.java index 221ee0bc..36b13cd2 100644 --- a/sample/src/main/java/io/noties/markwon/sample/Sample.java +++ b/sample/src/main/java/io/noties/markwon/sample/Sample.java @@ -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; diff --git a/sample/src/main/java/io/noties/markwon/sample/htmldetails/HtmlDetailsActivity.java b/sample/src/main/java/io/noties/markwon/sample/htmldetails/HtmlDetailsActivity.java new file mode 100644 index 00000000..8c668cf4 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/htmldetails/HtmlDetailsActivity.java @@ -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
\n" + + " stuff with \n\n*mark* **down**\n\n\n" + + "

\n\n" + + "\n" + + "## *formatted* **heading** with [a](link)\n" + + "```java\n" + + "code block\n" + + "```\n" + + "\n" + + "

\n" + + " nested stuff

\n" + + "\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" + + "

The 3rd!\n\n" + + "**bold** _em_\n
" + + "

\n" + + "

\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 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 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 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 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 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 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); + } + } + } + } +} diff --git a/sample/src/main/res/layout/activity_html_details.xml b/sample/src/main/res/layout/activity_html_details.xml new file mode 100644 index 00000000..7a61f5d8 --- /dev/null +++ b/sample/src/main/res/layout/activity_html_details.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/layout/view_html_details_text_view.xml b/sample/src/main/res/layout/view_html_details_text_view.xml new file mode 100644 index 00000000..36775ea9 --- /dev/null +++ b/sample/src/main/res/layout/view_html_details_text_view.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/sample/src/main/res/values/strings-samples.xml b/sample/src/main/res/values/strings-samples.xml index a26f62c5..d87585fd 100644 --- a/sample/src/main/res/values/strings-samples.xml +++ b/sample/src/main/res/values/strings-samples.xml @@ -29,4 +29,6 @@ # \# Inline Parser\n\nUsage of custom inline parser + # \# HTML <details> tag\n\n<details> tag parsed and rendered + \ No newline at end of file