Proper delimiters matching and autolinking support
This commit is contained in:
parent
5e3ace0c29
commit
681a7f68d7
@ -5,6 +5,8 @@
|
|||||||
* `Markwon#configuration` method to expose `MarkwonConfiguration` via public API
|
* `Markwon#configuration` method to expose `MarkwonConfiguration` via public API
|
||||||
* `HeadingSpan#getLevel` getter
|
* `HeadingSpan#getLevel` getter
|
||||||
* Add `SvgPictureMediaDecoder` in `image` module to deal with SVG without dimensions ([#165])
|
* Add `SvgPictureMediaDecoder` in `image` module to deal with SVG without dimensions ([#165])
|
||||||
|
* `LinkSpan#getLink` method
|
||||||
|
* `LinkifyPlugin` applies link span that is configured by `Markwon` (obtain via span factory)
|
||||||
|
|
||||||
[#165]: https://github.com/noties/Markwon/issues/165
|
[#165]: https://github.com/noties/Markwon/issues/165
|
||||||
|
|
||||||
|
@ -31,7 +31,15 @@ public class LinkSpan extends URLSpan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateDrawState(TextPaint ds) {
|
public void updateDrawState(@NonNull TextPaint ds) {
|
||||||
theme.applyLinkStyle(ds);
|
theme.applyLinkStyle(ds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 4.2.0-SNAPSHOT
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public String getLink() {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,12 @@ class MarkwonEditorImpl extends MarkwonEditor {
|
|||||||
public void process(@NonNull Editable editable) {
|
public void process(@NonNull Editable editable) {
|
||||||
|
|
||||||
final String input = editable.toString();
|
final String input = editable.toString();
|
||||||
final Spanned renderedMarkdown = markwon.toMarkdown(input);
|
|
||||||
|
// NB, we cast to Spannable here without prior checks
|
||||||
|
// if by some occasion Markwon stops returning here a Spannable our tests will catch that
|
||||||
|
// (we need Spannable in order to remove processed spans, so they do not appear multiple times)
|
||||||
|
final Spannable renderedMarkdown = (Spannable) markwon.toMarkdown(input);
|
||||||
|
|
||||||
final String markdown = renderedMarkdown.toString();
|
final String markdown = renderedMarkdown.toString();
|
||||||
|
|
||||||
final EditSpanHandler editSpanHandler = this.editSpanHandler;
|
final EditSpanHandler editSpanHandler = this.editSpanHandler;
|
||||||
@ -71,9 +76,14 @@ class MarkwonEditorImpl extends MarkwonEditor {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (hasAdditionalSpans) {
|
if (hasAdditionalSpans) {
|
||||||
|
// obtain spans for a single character of renderedMarkdown
|
||||||
|
// editable here should return all spans that are contained in specified
|
||||||
|
// region. Later we match if span starts at current position
|
||||||
|
// and notify additional span handler about it
|
||||||
final Object[] spans = renderedMarkdown.getSpans(markdownLength, markdownLength + 1, Object.class);
|
final Object[] spans = renderedMarkdown.getSpans(markdownLength, markdownLength + 1, Object.class);
|
||||||
for (Object span : spans) {
|
for (Object span : spans) {
|
||||||
if (markdownLength == renderedMarkdown.getSpanStart(span)) {
|
if (markdownLength == renderedMarkdown.getSpanStart(span)) {
|
||||||
|
|
||||||
editSpanHandler.handle(
|
editSpanHandler.handle(
|
||||||
store,
|
store,
|
||||||
editable,
|
editable,
|
||||||
@ -84,19 +94,53 @@ class MarkwonEditorImpl extends MarkwonEditor {
|
|||||||
// NB, we do not break here in case of SpanFactory
|
// NB, we do not break here in case of SpanFactory
|
||||||
// returns multiple spans for a markdown node, this way
|
// returns multiple spans for a markdown node, this way
|
||||||
// we will handle all of them
|
// we will handle all of them
|
||||||
|
|
||||||
|
// It is important to remove span after we have processed it
|
||||||
|
// as we process them in 2 places: here and in EQUAL
|
||||||
|
renderedMarkdown.removeSpan(span);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case INSERT:
|
case INSERT:
|
||||||
|
// no special handling here, but still we must advance the markdownLength
|
||||||
markdownLength += diff.text.length();
|
markdownLength += diff.text.length();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EQUAL:
|
case EQUAL:
|
||||||
final int length = diff.text.length();
|
final int length = diff.text.length();
|
||||||
|
final int inputStart = inputLength;
|
||||||
|
final int markdownStart = markdownLength;
|
||||||
inputLength += length;
|
inputLength += length;
|
||||||
markdownLength += length;
|
markdownLength += length;
|
||||||
|
|
||||||
|
// it is possible that there are spans for the text that is the same
|
||||||
|
// for example, if some links were _autolinked_ (text is the same,
|
||||||
|
// but there is an additional URLSpan)
|
||||||
|
if (hasAdditionalSpans) {
|
||||||
|
final Object[] spans = renderedMarkdown.getSpans(markdownStart, markdownLength, Object.class);
|
||||||
|
for (Object span : spans) {
|
||||||
|
final int spanStart = renderedMarkdown.getSpanStart(span);
|
||||||
|
if (spanStart >= markdownStart) {
|
||||||
|
final int end = renderedMarkdown.getSpanEnd(span);
|
||||||
|
if (end <= markdownLength) {
|
||||||
|
|
||||||
|
editSpanHandler.handle(
|
||||||
|
store,
|
||||||
|
editable,
|
||||||
|
input,
|
||||||
|
span,
|
||||||
|
// shift span to input position (can be different from the text itself)
|
||||||
|
inputStart + (spanStart - markdownStart),
|
||||||
|
end - spanStart
|
||||||
|
);
|
||||||
|
|
||||||
|
renderedMarkdown.removeSpan(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
@ -81,6 +81,10 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher {
|
|||||||
private final MarkwonEditor editor;
|
private final MarkwonEditor editor;
|
||||||
private final ExecutorService executorService;
|
private final ExecutorService executorService;
|
||||||
|
|
||||||
|
// As we operate on a single thread (main) we are fine with a regular int
|
||||||
|
// for marking current _generation_
|
||||||
|
private int generator;
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private EditText editText;
|
private EditText editText;
|
||||||
|
|
||||||
@ -115,8 +119,8 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: maybe checking hash is not so performant?
|
// both will be the same here (generator incremented and key assigned incremented value)
|
||||||
// what if we create a atomic reference and use it (with tag applied to editText)?
|
final int key = ++this.generator;
|
||||||
|
|
||||||
if (future != null) {
|
if (future != null) {
|
||||||
future.cancel(true);
|
future.cancel(true);
|
||||||
@ -129,11 +133,10 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher {
|
|||||||
@Override
|
@Override
|
||||||
public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) {
|
public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) {
|
||||||
if (editText != null) {
|
if (editText != null) {
|
||||||
final int key = key(result.resultEditable());
|
|
||||||
editText.post(new Runnable() {
|
editText.post(new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
if (key == key(editText.getText())) {
|
if (key == generator) {
|
||||||
selfChange = true;
|
selfChange = true;
|
||||||
try {
|
try {
|
||||||
result.dispatchTo(editText.getText());
|
result.dispatchTo(editText.getText());
|
||||||
@ -149,12 +152,5 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static int key(@NonNull Editable editable) {
|
|
||||||
// toString is important here, as using #hashCode directly
|
|
||||||
// would also check for spans (and some spans can be added/removed). This is why
|
|
||||||
// we are checking for exact match of text
|
|
||||||
return editable.toString().hashCode();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,152 @@
|
|||||||
|
package io.noties.markwon.editor;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 4.2.0-SNAPSHOT
|
||||||
|
*/
|
||||||
|
public abstract class MarkwonEditorUtils {
|
||||||
|
|
||||||
|
public interface Match {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
String delimiter();
|
||||||
|
|
||||||
|
int start();
|
||||||
|
|
||||||
|
int end();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static Match findDelimited(@NonNull String input, int startFrom, @NonNull String delimiter) {
|
||||||
|
final int start = input.indexOf(delimiter, startFrom);
|
||||||
|
if (start > -1) {
|
||||||
|
final int length = delimiter.length();
|
||||||
|
final int end = input.indexOf(delimiter, start + length);
|
||||||
|
if (end > -1) {
|
||||||
|
return new MatchImpl(delimiter, start, end + length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static Match findDelimited(
|
||||||
|
@NonNull String input,
|
||||||
|
int start,
|
||||||
|
@NonNull String delimiter1,
|
||||||
|
@NonNull String delimiter2) {
|
||||||
|
|
||||||
|
final int l1 = delimiter1.length();
|
||||||
|
final int l2 = delimiter2.length();
|
||||||
|
|
||||||
|
final char c1 = delimiter1.charAt(0);
|
||||||
|
final char c2 = delimiter2.charAt(0);
|
||||||
|
|
||||||
|
char c;
|
||||||
|
char previousC = 0;
|
||||||
|
|
||||||
|
Match match;
|
||||||
|
|
||||||
|
for (int i = start, length = input.length(); i < length; i++) {
|
||||||
|
c = input.charAt(i);
|
||||||
|
|
||||||
|
// if this char is the same as previous (and we obviously have no match) -> skip
|
||||||
|
if (c == previousC) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == c1) {
|
||||||
|
match = matchDelimiter(input, i, length, delimiter1, l1);
|
||||||
|
if (match != null) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
} else if (c == c2) {
|
||||||
|
match = matchDelimiter(input, i, length, delimiter2, l2);
|
||||||
|
if (match != null) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousC = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method assumes that first char is matched already
|
||||||
|
@Nullable
|
||||||
|
private static Match matchDelimiter(
|
||||||
|
@NonNull String input,
|
||||||
|
int start,
|
||||||
|
int length,
|
||||||
|
@NonNull String delimiter,
|
||||||
|
int delimiterLength) {
|
||||||
|
|
||||||
|
if (start + delimiterLength < length) {
|
||||||
|
|
||||||
|
boolean result = true;
|
||||||
|
|
||||||
|
for (int i = 1; i < delimiterLength; i++) {
|
||||||
|
if (input.charAt(start + i) != delimiter.charAt(i)) {
|
||||||
|
result = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// find end
|
||||||
|
final int end = input.indexOf(delimiter, start + delimiterLength);
|
||||||
|
// it's important to check if match has content
|
||||||
|
if (end > -1 && (end - start) > delimiterLength) {
|
||||||
|
return new MatchImpl(delimiter, start, end + delimiterLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MarkwonEditorUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MatchImpl implements Match {
|
||||||
|
|
||||||
|
private final String delimiter;
|
||||||
|
private final int start;
|
||||||
|
private final int end;
|
||||||
|
|
||||||
|
MatchImpl(@NonNull String delimiter, int start, int end) {
|
||||||
|
this.delimiter = delimiter;
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public String delimiter() {
|
||||||
|
return delimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int start() {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int end() {
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String toString() {
|
||||||
|
return "MatchImpl{" +
|
||||||
|
"delimiter='" + delimiter + '\'' +
|
||||||
|
", start=" + start +
|
||||||
|
", end=" + end +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
package io.noties.markwon.editor;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.robolectric.RobolectricTestRunner;
|
||||||
|
import org.robolectric.annotation.Config;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import io.noties.markwon.editor.MarkwonEditorUtils.Match;
|
||||||
|
|
||||||
|
import static io.noties.markwon.editor.MarkwonEditorUtils.findDelimited;
|
||||||
|
import static java.lang.String.format;
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner.class)
|
||||||
|
@Config(manifest = Config.NONE)
|
||||||
|
public class MarkwonEditorUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void delimited_single() {
|
||||||
|
final String input = "**bold**";
|
||||||
|
final Match match = findDelimited(input, 0, "**");
|
||||||
|
assertMatched(input, match, "**", 0, input.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void delimited_multiple() {
|
||||||
|
final String input = "**bold**";
|
||||||
|
final Match match = findDelimited(input, 0, "**", "__");
|
||||||
|
assertMatched(input, match, "**", 0, input.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void delimited_em() {
|
||||||
|
// for example we will try to match `*` or `_` and our implementation will find first
|
||||||
|
final String input = "**_em_**"; // problematic for em...
|
||||||
|
final Match match = findDelimited(input, 0, "_", "*");
|
||||||
|
assertMatched(input, match, "_", 2, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void delimited_bold_em_strike() {
|
||||||
|
final String input = "**_~~dude~~_**";
|
||||||
|
|
||||||
|
final Match bold = findDelimited(input, 0, "**", "__");
|
||||||
|
final Match em = findDelimited(input, 0, "*", "_");
|
||||||
|
final Match strike = findDelimited(input, 0, "~~");
|
||||||
|
|
||||||
|
assertMatched(input, bold, "**", 0, input.length());
|
||||||
|
assertMatched(input, em, "_", 2, 12);
|
||||||
|
assertMatched(input, strike, "~~", 3, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertMatched(
|
||||||
|
@NonNull String input,
|
||||||
|
@Nullable Match match,
|
||||||
|
@NonNull String delimiter,
|
||||||
|
int start,
|
||||||
|
int end) {
|
||||||
|
assertNotNull(format(Locale.ROOT, "delimiter: '%s', input: '%s'", delimiter, input), match);
|
||||||
|
final String m = format(Locale.ROOT, "input: '%s', match: %s", input, match);
|
||||||
|
assertEquals(m, delimiter, match.delimiter());
|
||||||
|
assertEquals(m, start, match.start());
|
||||||
|
assertEquals(m, end, match.end());
|
||||||
|
}
|
||||||
|
}
|
@ -1,18 +1,24 @@
|
|||||||
package io.noties.markwon.linkify;
|
package io.noties.markwon.linkify;
|
||||||
|
|
||||||
import android.text.SpannableStringBuilder;
|
import android.text.SpannableStringBuilder;
|
||||||
|
import android.text.style.URLSpan;
|
||||||
import android.text.util.Linkify;
|
import android.text.util.Linkify;
|
||||||
|
|
||||||
import androidx.annotation.IntDef;
|
import androidx.annotation.IntDef;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.commonmark.node.Link;
|
||||||
|
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
|
||||||
import io.noties.markwon.AbstractMarkwonPlugin;
|
import io.noties.markwon.AbstractMarkwonPlugin;
|
||||||
import io.noties.markwon.MarkwonVisitor;
|
import io.noties.markwon.MarkwonVisitor;
|
||||||
|
import io.noties.markwon.RenderProps;
|
||||||
|
import io.noties.markwon.SpanFactory;
|
||||||
import io.noties.markwon.SpannableBuilder;
|
import io.noties.markwon.SpannableBuilder;
|
||||||
import io.noties.markwon.core.CorePlugin;
|
import io.noties.markwon.core.CorePlugin;
|
||||||
|
import io.noties.markwon.core.CoreProps;
|
||||||
|
|
||||||
public class LinkifyPlugin extends AbstractMarkwonPlugin {
|
public class LinkifyPlugin extends AbstractMarkwonPlugin {
|
||||||
|
|
||||||
@ -66,6 +72,13 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin {
|
|||||||
@Override
|
@Override
|
||||||
public void onTextAdded(@NonNull MarkwonVisitor visitor, @NonNull String text, int start) {
|
public void onTextAdded(@NonNull MarkwonVisitor visitor, @NonNull String text, int start) {
|
||||||
|
|
||||||
|
// @since 4.2.0-SNAPSHOT obtain span factory for links
|
||||||
|
// we will be using the link that is used by markdown (instead of directly applying URLSpan)
|
||||||
|
final SpanFactory spanFactory = visitor.configuration().spansFactory().get(Link.class);
|
||||||
|
if (spanFactory == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// clear previous state
|
// clear previous state
|
||||||
builder.clear();
|
builder.clear();
|
||||||
builder.clearSpans();
|
builder.clearSpans();
|
||||||
@ -74,16 +87,22 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin {
|
|||||||
builder.append(text);
|
builder.append(text);
|
||||||
|
|
||||||
if (Linkify.addLinks(builder, mask)) {
|
if (Linkify.addLinks(builder, mask)) {
|
||||||
final Object[] spans = builder.getSpans(0, builder.length(), Object.class);
|
// target URL span specifically
|
||||||
|
final URLSpan[] spans = builder.getSpans(0, builder.length(), URLSpan.class);
|
||||||
if (spans != null
|
if (spans != null
|
||||||
&& spans.length > 0) {
|
&& spans.length > 0) {
|
||||||
|
|
||||||
|
final RenderProps renderProps = visitor.renderProps();
|
||||||
final SpannableBuilder spannableBuilder = visitor.builder();
|
final SpannableBuilder spannableBuilder = visitor.builder();
|
||||||
for (Object span : spans) {
|
|
||||||
spannableBuilder.setSpan(
|
for (URLSpan span : spans) {
|
||||||
span,
|
CoreProps.LINK_DESTINATION.set(renderProps, span.getURL());
|
||||||
|
SpannableBuilder.setSpans(
|
||||||
|
spannableBuilder,
|
||||||
|
spanFactory.getSpans(visitor.configuration(), renderProps),
|
||||||
start + builder.getSpanStart(span),
|
start + builder.getSpanStart(span),
|
||||||
start + builder.getSpanEnd(span),
|
start + builder.getSpanEnd(span)
|
||||||
builder.getSpanFlags(span));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,10 +41,11 @@ dependencies {
|
|||||||
implementation project(':markwon-ext-tasklist')
|
implementation project(':markwon-ext-tasklist')
|
||||||
implementation project(':markwon-html')
|
implementation project(':markwon-html')
|
||||||
implementation project(':markwon-image')
|
implementation project(':markwon-image')
|
||||||
implementation project(':markwon-syntax-highlight')
|
implementation project(':markwon-linkify')
|
||||||
implementation project(':markwon-recycler')
|
implementation project(':markwon-recycler')
|
||||||
implementation project(':markwon-recycler-table')
|
implementation project(':markwon-recycler-table')
|
||||||
implementation project(':markwon-simple-ext')
|
implementation project(':markwon-simple-ext')
|
||||||
|
implementation project(':markwon-syntax-highlight')
|
||||||
|
|
||||||
implementation project(':markwon-image-picasso')
|
implementation project(':markwon-image-picasso')
|
||||||
|
|
||||||
|
@ -6,7 +6,8 @@ import android.text.Editable;
|
|||||||
import android.text.SpannableStringBuilder;
|
import android.text.SpannableStringBuilder;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
import android.text.TextPaint;
|
import android.text.TextPaint;
|
||||||
import android.text.style.CharacterStyle;
|
import android.text.method.LinkMovementMethod;
|
||||||
|
import android.text.style.ClickableSpan;
|
||||||
import android.text.style.ForegroundColorSpan;
|
import android.text.style.ForegroundColorSpan;
|
||||||
import android.text.style.MetricAffectingSpan;
|
import android.text.style.MetricAffectingSpan;
|
||||||
import android.text.style.StrikethroughSpan;
|
import android.text.style.StrikethroughSpan;
|
||||||
@ -25,7 +26,6 @@ import java.util.concurrent.Executors;
|
|||||||
import io.noties.markwon.Markwon;
|
import io.noties.markwon.Markwon;
|
||||||
import io.noties.markwon.core.MarkwonTheme;
|
import io.noties.markwon.core.MarkwonTheme;
|
||||||
import io.noties.markwon.core.spans.BlockQuoteSpan;
|
import io.noties.markwon.core.spans.BlockQuoteSpan;
|
||||||
import io.noties.markwon.core.spans.CodeBlockSpan;
|
|
||||||
import io.noties.markwon.core.spans.CodeSpan;
|
import io.noties.markwon.core.spans.CodeSpan;
|
||||||
import io.noties.markwon.core.spans.EmphasisSpan;
|
import io.noties.markwon.core.spans.EmphasisSpan;
|
||||||
import io.noties.markwon.core.spans.LinkSpan;
|
import io.noties.markwon.core.spans.LinkSpan;
|
||||||
@ -33,6 +33,7 @@ import io.noties.markwon.core.spans.StrongEmphasisSpan;
|
|||||||
import io.noties.markwon.editor.EditSpanHandlerBuilder;
|
import io.noties.markwon.editor.EditSpanHandlerBuilder;
|
||||||
import io.noties.markwon.editor.MarkwonEditor;
|
import io.noties.markwon.editor.MarkwonEditor;
|
||||||
import io.noties.markwon.editor.MarkwonEditorTextWatcher;
|
import io.noties.markwon.editor.MarkwonEditorTextWatcher;
|
||||||
|
import io.noties.markwon.editor.MarkwonEditorUtils;
|
||||||
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin;
|
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin;
|
||||||
import io.noties.markwon.sample.R;
|
import io.noties.markwon.sample.R;
|
||||||
|
|
||||||
@ -148,79 +149,91 @@ public class EditorActivity extends Activity {
|
|||||||
|
|
||||||
private void multiple_edit_spans() {
|
private void multiple_edit_spans() {
|
||||||
|
|
||||||
|
// for links to be clickable
|
||||||
|
editText.setMovementMethod(LinkMovementMethod.getInstance());
|
||||||
|
|
||||||
final Markwon markwon = Markwon.builder(this)
|
final Markwon markwon = Markwon.builder(this)
|
||||||
.usePlugin(StrikethroughPlugin.create())
|
.usePlugin(StrikethroughPlugin.create())
|
||||||
|
// .usePlugin(LinkifyPlugin.create())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final MarkwonTheme theme = markwon.configuration().theme();
|
final MarkwonTheme theme = markwon.configuration().theme();
|
||||||
|
|
||||||
|
final EditLinkSpan.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link);
|
||||||
|
|
||||||
final MarkwonEditor editor = MarkwonEditor.builder(markwon)
|
final MarkwonEditor editor = MarkwonEditor.builder(markwon)
|
||||||
.includeEditSpan(StrongEmphasisSpan.class, StrongEmphasisSpan::new)
|
.includeEditSpan(StrongEmphasisSpan.class, StrongEmphasisSpan::new)
|
||||||
.includeEditSpan(EmphasisSpan.class, EmphasisSpan::new)
|
.includeEditSpan(EmphasisSpan.class, EmphasisSpan::new)
|
||||||
.includeEditSpan(StrikethroughSpan.class, StrikethroughSpan::new)
|
.includeEditSpan(StrikethroughSpan.class, StrikethroughSpan::new)
|
||||||
.includeEditSpan(CodeSpan.class, () -> new CodeSpan(theme))
|
.includeEditSpan(CodeSpan.class, () -> new CodeSpan(theme))
|
||||||
.includeEditSpan(CodeBlockSpan.class, () -> new CodeBlockSpan(theme))
|
|
||||||
.includeEditSpan(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme))
|
.includeEditSpan(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme))
|
||||||
.includeEditSpan(EditLinkSpan.class, EditLinkSpan::new)
|
.includeEditSpan(EditLinkSpan.class, () -> new EditLinkSpan(onClick))
|
||||||
.withEditSpanHandler(createEditSpanHandler())
|
.withEditSpanHandler(createEditSpanHandler())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
|
// editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
|
||||||
|
editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender(
|
||||||
|
editor, Executors.newSingleThreadExecutor(), editText));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MarkwonEditor.EditSpanHandler createEditSpanHandler() {
|
private static MarkwonEditor.EditSpanHandler createEditSpanHandler() {
|
||||||
// Please note that here we specify spans THAT ARE USED IN MARKDOWN
|
// Please note that here we specify spans THAT ARE USED IN MARKDOWN
|
||||||
return EditSpanHandlerBuilder.create()
|
return EditSpanHandlerBuilder.create()
|
||||||
.handleMarkdownSpan(StrongEmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
.handleMarkdownSpan(StrongEmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
||||||
|
// inline spans can delimit other inline spans,
|
||||||
|
// for example: `**_~~hey~~_**`, this is why we must additionally find delimiter used
|
||||||
|
// and its actual start/end positions
|
||||||
|
final MarkwonEditorUtils.Match match =
|
||||||
|
MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__");
|
||||||
|
if (match != null) {
|
||||||
editable.setSpan(
|
editable.setSpan(
|
||||||
store.get(StrongEmphasisSpan.class),
|
store.get(StrongEmphasisSpan.class),
|
||||||
spanStart,
|
match.start(),
|
||||||
spanStart + spanTextLength + 4,
|
match.end(),
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
);
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.handleMarkdownSpan(EmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
.handleMarkdownSpan(EmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
||||||
|
final MarkwonEditorUtils.Match match =
|
||||||
|
MarkwonEditorUtils.findDelimited(input, spanStart, "*", "_");
|
||||||
|
if (match != null) {
|
||||||
editable.setSpan(
|
editable.setSpan(
|
||||||
store.get(EmphasisSpan.class),
|
store.get(EmphasisSpan.class),
|
||||||
spanStart,
|
match.start(),
|
||||||
spanStart + spanTextLength + 2,
|
match.end(),
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
);
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.handleMarkdownSpan(StrikethroughSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
.handleMarkdownSpan(StrikethroughSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
||||||
|
final MarkwonEditorUtils.Match match =
|
||||||
|
MarkwonEditorUtils.findDelimited(input, spanStart, "~~");
|
||||||
|
if (match != null) {
|
||||||
editable.setSpan(
|
editable.setSpan(
|
||||||
store.get(StrikethroughSpan.class),
|
store.get(StrikethroughSpan.class),
|
||||||
spanStart,
|
match.start(),
|
||||||
spanStart + spanTextLength + 4,
|
match.end(),
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
);
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.handleMarkdownSpan(CodeSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
.handleMarkdownSpan(CodeSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
||||||
// we do not add offset here because markwon (by default) adds spaces
|
// we do not add offset here because markwon (by default) adds spaces
|
||||||
// around inline code
|
// around inline code
|
||||||
|
final MarkwonEditorUtils.Match match =
|
||||||
|
MarkwonEditorUtils.findDelimited(input, spanStart, "`");
|
||||||
|
if (match != null) {
|
||||||
editable.setSpan(
|
editable.setSpan(
|
||||||
store.get(CodeSpan.class),
|
store.get(CodeSpan.class),
|
||||||
spanStart,
|
match.start(),
|
||||||
spanStart + spanTextLength,
|
match.end(),
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.handleMarkdownSpan(CodeBlockSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
|
||||||
// we do not handle indented code blocks here
|
|
||||||
if (input.charAt(spanStart) == '`') {
|
|
||||||
final int firstLineEnd = input.indexOf('\n', spanStart);
|
|
||||||
if (firstLineEnd == -1) return;
|
|
||||||
int lastLineEnd = input.indexOf('\n', spanStart + (firstLineEnd - spanStart) + spanTextLength + 1);
|
|
||||||
if (lastLineEnd == -1) lastLineEnd = input.length();
|
|
||||||
|
|
||||||
editable.setSpan(
|
|
||||||
store.get(CodeBlockSpan.class),
|
|
||||||
spanStart,
|
|
||||||
lastLineEnd,
|
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.handleMarkdownSpan(BlockQuoteSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
.handleMarkdownSpan(BlockQuoteSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
||||||
|
// todo: here we should actually find a proper ending of a block quote...
|
||||||
editable.setSpan(
|
editable.setSpan(
|
||||||
store.get(BlockQuoteSpan.class),
|
store.get(BlockQuoteSpan.class),
|
||||||
spanStart,
|
spanStart,
|
||||||
@ -229,11 +242,27 @@ public class EditorActivity extends Activity {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.handleMarkdownSpan(LinkSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
.handleMarkdownSpan(LinkSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
|
||||||
|
|
||||||
|
final EditLinkSpan editLinkSpan = store.get(EditLinkSpan.class);
|
||||||
|
editLinkSpan.link = span.getLink();
|
||||||
|
|
||||||
|
final int s;
|
||||||
|
final int e;
|
||||||
|
|
||||||
|
// markdown link vs. autolink
|
||||||
|
if ('[' == input.charAt(spanStart)) {
|
||||||
|
s = spanStart + 1;
|
||||||
|
e = spanStart + 1 + spanTextLength;
|
||||||
|
} else {
|
||||||
|
s = spanStart;
|
||||||
|
e = spanStart + spanTextLength;
|
||||||
|
}
|
||||||
|
|
||||||
editable.setSpan(
|
editable.setSpan(
|
||||||
store.get(EditLinkSpan.class),
|
editLinkSpan,
|
||||||
// add underline only for link text
|
// add underline only for link text
|
||||||
spanStart + 1,
|
s,
|
||||||
spanStart + 1 + spanTextLength,
|
e,
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -260,8 +289,14 @@ public class EditorActivity extends Activity {
|
|||||||
code.setOnClickListener(new InsertOrWrapClickListener(editText, "`"));
|
code.setOnClickListener(new InsertOrWrapClickListener(editText, "`"));
|
||||||
|
|
||||||
quote.setOnClickListener(v -> {
|
quote.setOnClickListener(v -> {
|
||||||
|
|
||||||
final int start = editText.getSelectionStart();
|
final int start = editText.getSelectionStart();
|
||||||
final int end = editText.getSelectionEnd();
|
final int end = editText.getSelectionEnd();
|
||||||
|
|
||||||
|
if (start < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (start == end) {
|
if (start == end) {
|
||||||
editText.getText().insert(start, "> ");
|
editText.getText().insert(start, "> ");
|
||||||
} else {
|
} else {
|
||||||
@ -306,6 +341,11 @@ public class EditorActivity extends Activity {
|
|||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
final int start = editText.getSelectionStart();
|
final int start = editText.getSelectionStart();
|
||||||
final int end = editText.getSelectionEnd();
|
final int end = editText.getSelectionEnd();
|
||||||
|
|
||||||
|
if (start < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (start == end) {
|
if (start == end) {
|
||||||
// insert at current position
|
// insert at current position
|
||||||
editText.getText().insert(start, text);
|
editText.getText().insert(start, text);
|
||||||
@ -342,11 +382,25 @@ public class EditorActivity extends Activity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class EditLinkSpan extends CharacterStyle {
|
private static class EditLinkSpan extends ClickableSpan {
|
||||||
|
|
||||||
|
interface OnClick {
|
||||||
|
void onClick(@NonNull View widget, @NonNull String link);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final OnClick onClick;
|
||||||
|
|
||||||
|
String link;
|
||||||
|
|
||||||
|
EditLinkSpan(@NonNull OnClick onClick) {
|
||||||
|
this.onClick = onClick;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateDrawState(TextPaint tp) {
|
public void onClick(@NonNull View widget) {
|
||||||
tp.setColor(tp.linkColor);
|
if (link != null) {
|
||||||
tp.setUnderlineText(true);
|
onClick.onClick(widget, link);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user