From 681a7f68d74a9c141e4d68cecbee81d023dcf72b Mon Sep 17 00:00:00 2001 From: Dimitry Ivanov Date: Fri, 8 Nov 2019 17:55:11 +0300 Subject: [PATCH] Proper delimiters matching and autolinking support --- CHANGELOG.md | 2 + .../noties/markwon/core/spans/LinkSpan.java | 10 +- .../markwon/editor/MarkwonEditorImpl.java | 46 +++++- .../editor/MarkwonEditorTextWatcher.java | 18 +-- .../markwon/editor/MarkwonEditorUtils.java | 152 ++++++++++++++++++ .../editor/MarkwonEditorUtilsTest.java | 71 ++++++++ .../noties/markwon/linkify/LinkifyPlugin.java | 31 +++- sample/build.gradle | 3 +- .../markwon/sample/editor/EditorActivity.java | 150 +++++++++++------ 9 files changed, 415 insertions(+), 68 deletions(-) create mode 100644 markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java create mode 100644 markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9350d8ef..9daad6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * `Markwon#configuration` method to expose `MarkwonConfiguration` via public API * `HeadingSpan#getLevel` getter * 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 diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java index f8483423..481e3c0a 100644 --- a/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java +++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java @@ -31,7 +31,15 @@ public class LinkSpan extends URLSpan { } @Override - public void updateDrawState(TextPaint ds) { + public void updateDrawState(@NonNull TextPaint ds) { theme.applyLinkStyle(ds); } + + /** + * @since 4.2.0-SNAPSHOT + */ + @NonNull + public String getLink() { + return link; + } } diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorImpl.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorImpl.java index 7f948f3f..32c56e63 100644 --- a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorImpl.java +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorImpl.java @@ -41,7 +41,12 @@ class MarkwonEditorImpl extends MarkwonEditor { public void process(@NonNull Editable editable) { 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 EditSpanHandler editSpanHandler = this.editSpanHandler; @@ -71,9 +76,14 @@ class MarkwonEditorImpl extends MarkwonEditor { ); 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); for (Object span : spans) { if (markdownLength == renderedMarkdown.getSpanStart(span)) { + editSpanHandler.handle( store, editable, @@ -84,19 +94,53 @@ class MarkwonEditorImpl extends MarkwonEditor { // NB, we do not break here in case of SpanFactory // returns multiple spans for a markdown node, this way // 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; case INSERT: + // no special handling here, but still we must advance the markdownLength markdownLength += diff.text.length(); break; case EQUAL: final int length = diff.text.length(); + final int inputStart = inputLength; + final int markdownStart = markdownLength; inputLength += 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; default: diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorTextWatcher.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorTextWatcher.java index 47a0cf8c..5d7acd26 100644 --- a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorTextWatcher.java +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorTextWatcher.java @@ -81,6 +81,10 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher { private final MarkwonEditor editor; 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 private EditText editText; @@ -115,8 +119,8 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher { return; } - // todo: maybe checking hash is not so performant? - // what if we create a atomic reference and use it (with tag applied to editText)? + // both will be the same here (generator incremented and key assigned incremented value) + final int key = ++this.generator; if (future != null) { future.cancel(true); @@ -129,11 +133,10 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher { @Override public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) { if (editText != null) { - final int key = key(result.resultEditable()); editText.post(new Runnable() { @Override public void run() { - if (key == key(editText.getText())) { + if (key == generator) { selfChange = true; try { 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(); - } } } diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java new file mode 100644 index 00000000..d2824bdb --- /dev/null +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java @@ -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 + + '}'; + } + } +} diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java new file mode 100644 index 00000000..d53f1cb4 --- /dev/null +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java @@ -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()); + } +} diff --git a/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java b/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java index ad9793b2..3af48dcd 100644 --- a/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java +++ b/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java @@ -1,18 +1,24 @@ package io.noties.markwon.linkify; import android.text.SpannableStringBuilder; +import android.text.style.URLSpan; import android.text.util.Linkify; import androidx.annotation.IntDef; import androidx.annotation.NonNull; +import org.commonmark.node.Link; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.RenderProps; +import io.noties.markwon.SpanFactory; import io.noties.markwon.SpannableBuilder; import io.noties.markwon.core.CorePlugin; +import io.noties.markwon.core.CoreProps; public class LinkifyPlugin extends AbstractMarkwonPlugin { @@ -66,6 +72,13 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin { @Override 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 builder.clear(); builder.clearSpans(); @@ -74,16 +87,22 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin { builder.append(text); 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 && spans.length > 0) { + + final RenderProps renderProps = visitor.renderProps(); final SpannableBuilder spannableBuilder = visitor.builder(); - for (Object span : spans) { - spannableBuilder.setSpan( - span, + + for (URLSpan span : spans) { + CoreProps.LINK_DESTINATION.set(renderProps, span.getURL()); + SpannableBuilder.setSpans( + spannableBuilder, + spanFactory.getSpans(visitor.configuration(), renderProps), start + builder.getSpanStart(span), - start + builder.getSpanEnd(span), - builder.getSpanFlags(span)); + start + builder.getSpanEnd(span) + ); } } } diff --git a/sample/build.gradle b/sample/build.gradle index 75e482cd..a7dec247 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -41,10 +41,11 @@ dependencies { implementation project(':markwon-ext-tasklist') implementation project(':markwon-html') implementation project(':markwon-image') - implementation project(':markwon-syntax-highlight') + implementation project(':markwon-linkify') implementation project(':markwon-recycler') implementation project(':markwon-recycler-table') implementation project(':markwon-simple-ext') + implementation project(':markwon-syntax-highlight') implementation project(':markwon-image-picasso') diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java index 25ff6a47..c09ada4b 100644 --- a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java @@ -6,7 +6,8 @@ import android.text.Editable; import android.text.SpannableStringBuilder; import android.text.Spanned; 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.MetricAffectingSpan; import android.text.style.StrikethroughSpan; @@ -25,7 +26,6 @@ import java.util.concurrent.Executors; import io.noties.markwon.Markwon; import io.noties.markwon.core.MarkwonTheme; 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.EmphasisSpan; 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.MarkwonEditor; import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.editor.MarkwonEditorUtils; import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; import io.noties.markwon.sample.R; @@ -148,79 +149,91 @@ public class EditorActivity extends Activity { private void multiple_edit_spans() { + // for links to be clickable + editText.setMovementMethod(LinkMovementMethod.getInstance()); + final Markwon markwon = Markwon.builder(this) .usePlugin(StrikethroughPlugin.create()) +// .usePlugin(LinkifyPlugin.create()) .build(); + final MarkwonTheme theme = markwon.configuration().theme(); + final EditLinkSpan.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); + final MarkwonEditor editor = MarkwonEditor.builder(markwon) .includeEditSpan(StrongEmphasisSpan.class, StrongEmphasisSpan::new) .includeEditSpan(EmphasisSpan.class, EmphasisSpan::new) .includeEditSpan(StrikethroughSpan.class, StrikethroughSpan::new) .includeEditSpan(CodeSpan.class, () -> new CodeSpan(theme)) - .includeEditSpan(CodeBlockSpan.class, () -> new CodeBlockSpan(theme)) .includeEditSpan(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme)) - .includeEditSpan(EditLinkSpan.class, EditLinkSpan::new) + .includeEditSpan(EditLinkSpan.class, () -> new EditLinkSpan(onClick)) .withEditSpanHandler(createEditSpanHandler()) .build(); - editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); +// editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); + editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( + editor, Executors.newSingleThreadExecutor(), editText)); } private static MarkwonEditor.EditSpanHandler createEditSpanHandler() { // Please note that here we specify spans THAT ARE USED IN MARKDOWN return EditSpanHandlerBuilder.create() .handleMarkdownSpan(StrongEmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { - editable.setSpan( - store.get(StrongEmphasisSpan.class), - spanStart, - spanStart + spanTextLength + 4, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); + // 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( + store.get(StrongEmphasisSpan.class), + match.start(), + match.end(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } }) .handleMarkdownSpan(EmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { - editable.setSpan( - store.get(EmphasisSpan.class), - spanStart, - spanStart + spanTextLength + 2, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); + final MarkwonEditorUtils.Match match = + MarkwonEditorUtils.findDelimited(input, spanStart, "*", "_"); + if (match != null) { + editable.setSpan( + store.get(EmphasisSpan.class), + match.start(), + match.end(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } }) .handleMarkdownSpan(StrikethroughSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { - editable.setSpan( - store.get(StrikethroughSpan.class), - spanStart, - spanStart + spanTextLength + 4, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); + final MarkwonEditorUtils.Match match = + MarkwonEditorUtils.findDelimited(input, spanStart, "~~"); + if (match != null) { + editable.setSpan( + store.get(StrikethroughSpan.class), + match.start(), + match.end(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } }) .handleMarkdownSpan(CodeSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { // we do not add offset here because markwon (by default) adds spaces // around inline code - editable.setSpan( - store.get(CodeSpan.class), - spanStart, - spanStart + spanTextLength, - 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(); - + final MarkwonEditorUtils.Match match = + MarkwonEditorUtils.findDelimited(input, spanStart, "`"); + if (match != null) { editable.setSpan( - store.get(CodeBlockSpan.class), - spanStart, - lastLineEnd, + store.get(CodeSpan.class), + match.start(), + match.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); } }) .handleMarkdownSpan(BlockQuoteSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + // todo: here we should actually find a proper ending of a block quote... editable.setSpan( store.get(BlockQuoteSpan.class), spanStart, @@ -229,11 +242,27 @@ public class EditorActivity extends Activity { ); }) .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( - store.get(EditLinkSpan.class), + editLinkSpan, // add underline only for link text - spanStart + 1, - spanStart + 1 + spanTextLength, + s, + e, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); }) @@ -260,8 +289,14 @@ public class EditorActivity extends Activity { code.setOnClickListener(new InsertOrWrapClickListener(editText, "`")); quote.setOnClickListener(v -> { + final int start = editText.getSelectionStart(); final int end = editText.getSelectionEnd(); + + if (start < 0) { + return; + } + if (start == end) { editText.getText().insert(start, "> "); } else { @@ -306,6 +341,11 @@ public class EditorActivity extends Activity { public void onClick(View v) { final int start = editText.getSelectionStart(); final int end = editText.getSelectionEnd(); + + if (start < 0) { + return; + } + if (start == end) { // insert at current position 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 - public void updateDrawState(TextPaint tp) { - tp.setColor(tp.linkColor); - tp.setUnderlineText(true); + public void onClick(@NonNull View widget) { + if (link != null) { + onClick.onClick(widget, link); + } } } }