Proper delimiters matching and autolinking support

This commit is contained in:
Dimitry Ivanov 2019-11-08 17:55:11 +03:00
parent 5e3ace0c29
commit 681a7f68d7
9 changed files with 415 additions and 68 deletions

View File

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

View File

@ -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;
}
} }

View File

@ -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:

View File

@ -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();
}
} }
} }

View File

@ -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 +
'}';
}
}
}

View File

@ -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());
}
}

View File

@ -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)); );
} }
} }
} }

View File

@ -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')

View File

@ -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);
}
} }
} }
} }