Stabilizing editor API

This commit is contained in:
Dimitry Ivanov 2019-11-11 17:08:41 +03:00
parent a6201b1b35
commit c6fd779f33
21 changed files with 922 additions and 548 deletions

@ -0,0 +1,18 @@
package io.noties.markwon.editor;
import androidx.annotation.NonNull;
import io.noties.markwon.Markwon;
/**
* @see EditHandler
* @see io.noties.markwon.editor.handler.EmphasisEditHandler
* @see io.noties.markwon.editor.handler.StrongEmphasisEditHandler
* @since 4.2.0-SNAPSHOT
*/
public abstract class AbstractEditHandler<T> implements EditHandler<T> {
@Override
public void init(@NonNull Markwon markwon) {
}
}

@ -0,0 +1,47 @@
package io.noties.markwon.editor;
import android.text.Editable;
import androidx.annotation.NonNull;
import io.noties.markwon.Markwon;
import io.noties.markwon.editor.handler.EmphasisEditHandler;
import io.noties.markwon.editor.handler.StrongEmphasisEditHandler;
/**
* @see EmphasisEditHandler
* @see StrongEmphasisEditHandler
* @since 4.2.0-SNAPSHOT
*/
public interface EditHandler<T> {
void init(@NonNull Markwon markwon);
void configurePersistedSpans(@NonNull PersistedSpans.Builder builder);
// span is present only in off-screen rendered markdown, it must be processed and
// a NEW one must be added to editable (via edit-persist-spans)
//
// NB, editable.setSpan must obtain span from `spans` and must be configured beforehand
// multiple spans are OK as long as they are configured
/**
* @param persistedSpans
* @param editable
* @param input
* @param span
* @param spanStart
* @param spanTextLength
* @see MarkwonEditorUtils
*/
void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull T span,
int spanStart,
int spanTextLength);
@NonNull
Class<T> markdownSpanType();
}

@ -1,72 +0,0 @@
package io.noties.markwon.editor;
import android.text.Editable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
/**
* @since 4.2.0-SNAPSHOT
*/
public class EditSpanHandlerBuilder {
public interface EditSpanHandlerTyped<T> {
void handle(
@NonNull MarkwonEditor.EditSpanStore store,
@NonNull Editable editable,
@NonNull String input,
@NonNull T span,
int spanStart,
int spanTextLength);
}
@NonNull
public static EditSpanHandlerBuilder create() {
return new EditSpanHandlerBuilder();
}
private final Map<Class<?>, EditSpanHandlerTyped> map = new HashMap<>(3);
@NonNull
public <T> EditSpanHandlerBuilder handleMarkdownSpan(
@NonNull Class<T> type,
@NonNull EditSpanHandlerTyped<T> handler) {
map.put(type, handler);
return this;
}
@Nullable
public MarkwonEditor.EditSpanHandler build() {
if (map.size() == 0) {
return null;
}
return new EditSpanHandlerImpl(map);
}
private static class EditSpanHandlerImpl implements MarkwonEditor.EditSpanHandler {
private final Map<Class<?>, EditSpanHandlerTyped> map;
EditSpanHandlerImpl(@NonNull Map<Class<?>, EditSpanHandlerTyped> map) {
this.map = map;
}
@Override
public void handle(
@NonNull MarkwonEditor.EditSpanStore store,
@NonNull Editable editable,
@NonNull String input,
@NonNull Object span,
int spanStart,
int spanTextLength) {
final EditSpanHandlerTyped handler = map.get(span.getClass());
if (handler != null) {
//noinspection unchecked
handler.handle(store, editable, input, span, spanStart, spanTextLength);
}
}
}
}

@ -3,7 +3,6 @@ package io.noties.markwon.editor;
import android.text.Editable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
@ -20,44 +19,8 @@ import io.noties.markwon.Markwon;
public abstract class MarkwonEditor {
/**
* Represents cache of spans that are used during highlight
* @see #preRender(Editable, PreRenderResultListener)
*/
public interface EditSpanStore {
/**
* If a span of specified type was not registered with {@link Builder#includeEditSpan(Class, EditSpanFactory)}
* then an exception is raised.
*
* @param type of a span to obtain
* @return cached or newly created span
*/
@NonNull
<T> T get(Class<T> type);
}
public interface EditSpanFactory<T> {
@NonNull
T create();
}
/**
* Interface to handle _original_ span that is present in rendered markdown. Can be useful
* to add specific spans for EditText (for example, make text bold to better indicate
* strong emphasis used in markdown input).
*
* @see Builder#withEditSpanHandler(EditSpanHandler)
* @see EditSpanHandlerBuilder
*/
public interface EditSpanHandler {
void handle(
@NonNull EditSpanStore store,
@NonNull Editable editable,
@NonNull String input,
@NonNull Object span,
int spanStart,
int spanTextLength);
}
public interface PreRenderResult {
/**
@ -116,7 +79,7 @@ public abstract class MarkwonEditor {
* thread.
* <p>
* Please note that currently only `setSpan` and `removeSpan` actions will be recorded (and thus dispatched).
* Make sure you use only these methods in your {@link EditSpanHandler}, or implement the required
* Make sure you use only these methods in your {@link EditHandler}, or implement the required
* functionality some other way.
*
* @param editable to process and pre-render
@ -129,15 +92,22 @@ public abstract class MarkwonEditor {
public static class Builder {
private final Markwon markwon;
private final PersistedSpans.Provider persistedSpansProvider = PersistedSpans.provider();
private final Map<Class<?>, EditHandler> editHandlers = new HashMap<>(0);
private Class<?> punctuationSpanType;
private Map<Class<?>, EditSpanFactory> spans = new HashMap<>(3);
private EditSpanHandler editSpanHandler;
Builder(@NonNull Markwon markwon) {
this.markwon = markwon;
}
@NonNull
public <T> Builder useEditHandler(@NonNull EditHandler<T> handler) {
this.editHandlers.put(handler.markdownSpanType(), handler);
return this;
}
/**
* Specify which punctuation span will be used.
*
@ -145,43 +115,9 @@ public abstract class MarkwonEditor {
* @param factory to create a new instance of the span
*/
@NonNull
public <T> Builder withPunctuationSpan(@NonNull Class<T> type, @NonNull EditSpanFactory<T> factory) {
public <T> Builder punctuationSpan(@NonNull Class<T> type, @NonNull PersistedSpans.SpanFactory<T> factory) {
this.punctuationSpanType = type;
this.spans.put(type, factory);
return this;
}
/**
* Include additional span handling that is used in highlighting. It is important to understand
* that it is not the span that is used by Markwon, but instead your own span that you
* apply in a custom {@link EditSpanHandler} specified by {@link #withEditSpanHandler(EditSpanHandler)}.
* You can apply a Markwon bundled span (or any other) but it must be still explicitly
* included by this method.
* <p>
* The span will be exposed via {@link EditSpanStore} in your custom {@link EditSpanHandler}.
* If you do not use a custom {@link EditSpanHandler} you do not need to specify any span here.
*
* @param type of a span to include
* @param factory to create a new instance of a span if one is missing from processed Editable
*/
@NonNull
public <T> Builder includeEditSpan(
@NonNull Class<T> type,
@NonNull EditSpanFactory<T> factory) {
this.spans.put(type, factory);
return this;
}
/**
* Additional handling of markdown spans.
*
* @param editSpanHandler handler for additional highlight spans
* @see EditSpanHandler
* @see EditSpanHandlerBuilder
*/
@NonNull
public Builder withEditSpanHandler(@Nullable EditSpanHandler editSpanHandler) {
this.editSpanHandler = editSpanHandler;
this.persistedSpansProvider.persistSpan(type, factory);
return this;
}
@ -190,7 +126,7 @@ public abstract class MarkwonEditor {
Class<?> punctuationSpanType = this.punctuationSpanType;
if (punctuationSpanType == null) {
withPunctuationSpan(PunctuationSpan.class, new EditSpanFactory<PunctuationSpan>() {
punctuationSpan(PunctuationSpan.class, new PersistedSpans.SpanFactory<PunctuationSpan>() {
@NonNull
@Override
public PunctuationSpan create() {
@ -200,17 +136,54 @@ public abstract class MarkwonEditor {
punctuationSpanType = this.punctuationSpanType;
}
// if we have no editSpanHandler, but spans are registered -> throw an error
if (spans.size() > 1 && editSpanHandler == null) {
throw new IllegalStateException("There is no need to include edit spans " +
"when you do not use custom EditSpanHandler");
for (EditHandler handler : editHandlers.values()) {
handler.init(markwon);
handler.configurePersistedSpans(persistedSpansProvider);
}
final SpansHandler spansHandler = editHandlers.size() == 0
? null
: new SpansHandlerImpl(editHandlers);
return new MarkwonEditorImpl(
markwon,
spans,
persistedSpansProvider,
punctuationSpanType,
editSpanHandler);
spansHandler);
}
}
interface SpansHandler {
void handle(
@NonNull PersistedSpans spans,
@NonNull Editable editable,
@NonNull String input,
@NonNull Object span,
int spanStart,
int spanTextLength);
}
static class SpansHandlerImpl implements SpansHandler {
private final Map<Class<?>, EditHandler> spanHandlers;
SpansHandlerImpl(@NonNull Map<Class<?>, EditHandler> spanHandlers) {
this.spanHandlers = spanHandlers;
}
@Override
public void handle(
@NonNull PersistedSpans spans,
@NonNull Editable editable,
@NonNull String input,
@NonNull Object span,
int spanStart,
int spanTextLength) {
final EditHandler handler = spanHandlers.get(span.getClass());
if (handler != null) {
//noinspection unchecked
handler.handleMarkdownSpan(spans, editable, input, span, spanStart, spanTextLength);
}
}
}
}

@ -9,10 +9,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import io.noties.markwon.Markwon;
import io.noties.markwon.editor.diff_match_patch.Diff;
@ -20,21 +17,21 @@ import io.noties.markwon.editor.diff_match_patch.Diff;
class MarkwonEditorImpl extends MarkwonEditor {
private final Markwon markwon;
private final Map<Class<?>, EditSpanFactory> spans;
private final PersistedSpans.Provider persistedSpansProvider;
private final Class<?> punctuationSpanType;
@Nullable
private final EditSpanHandler editSpanHandler;
private final SpansHandler spansHandler;
MarkwonEditorImpl(
@NonNull Markwon markwon,
@NonNull Map<Class<?>, EditSpanFactory> spans,
@NonNull PersistedSpans.Provider persistedSpansProvider,
@NonNull Class<?> punctuationSpanType,
@Nullable EditSpanHandler editSpanHandler) {
@Nullable SpansHandler spansHandler) {
this.markwon = markwon;
this.spans = spans;
this.persistedSpansProvider = persistedSpansProvider;
this.punctuationSpanType = punctuationSpanType;
this.editSpanHandler = editSpanHandler;
this.spansHandler = spansHandler;
}
@Override
@ -49,10 +46,10 @@ class MarkwonEditorImpl extends MarkwonEditor {
final String markdown = renderedMarkdown.toString();
final EditSpanHandler editSpanHandler = this.editSpanHandler;
final boolean hasAdditionalSpans = editSpanHandler != null;
final SpansHandler spansHandler = this.spansHandler;
final boolean hasAdditionalSpans = spansHandler != null;
final EditSpanStoreImpl store = new EditSpanStoreImpl(editable, spans);
final PersistedSpans persistedSpans = persistedSpansProvider.provide(editable);
try {
final List<Diff> diffs = diff_match_patch.diff_main(input, markdown);
@ -68,8 +65,9 @@ class MarkwonEditorImpl extends MarkwonEditor {
final int start = inputLength;
inputLength += diff.text.length();
editable.setSpan(
store.get(punctuationSpanType),
persistedSpans.get(punctuationSpanType),
start,
inputLength,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@ -84,8 +82,8 @@ class MarkwonEditorImpl extends MarkwonEditor {
for (Object span : spans) {
if (markdownLength == renderedMarkdown.getSpanStart(span)) {
editSpanHandler.handle(
store,
spansHandler.handle(
persistedSpans,
editable,
input,
span,
@ -126,8 +124,8 @@ class MarkwonEditorImpl extends MarkwonEditor {
final int end = renderedMarkdown.getSpanEnd(span);
if (end <= markdownLength) {
editSpanHandler.handle(
store,
spansHandler.handle(
persistedSpans,
editable,
input,
span,
@ -149,7 +147,7 @@ class MarkwonEditorImpl extends MarkwonEditor {
}
} finally {
store.removeUnused();
persistedSpans.removeUnused();
}
}
@ -177,75 +175,6 @@ class MarkwonEditorImpl extends MarkwonEditor {
});
}
@NonNull
static Map<Class<?>, List<Object>> extractSpans(@NonNull Spanned spanned, @NonNull Collection<Class<?>> types) {
final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
final Map<Class<?>, List<Object>> map = new HashMap<>(3);
Class<?> type;
for (Object span : spans) {
type = span.getClass();
if (types.contains(type)) {
List<Object> list = map.get(type);
if (list == null) {
list = new ArrayList<>(3);
map.put(type, list);
}
list.add(span);
}
}
return map;
}
static class EditSpanStoreImpl implements EditSpanStore {
private final Spannable spannable;
private final Map<Class<?>, EditSpanFactory> spans;
private final Map<Class<?>, List<Object>> map;
EditSpanStoreImpl(@NonNull Spannable spannable, @NonNull Map<Class<?>, EditSpanFactory> spans) {
this.spannable = spannable;
this.spans = spans;
this.map = extractSpans(spannable, spans.keySet());
}
@NonNull
@Override
public <T> T get(Class<T> type) {
final Object span;
final List<Object> list = map.get(type);
if (list != null && list.size() > 0) {
span = list.remove(0);
} else {
final EditSpanFactory spanFactory = spans.get(type);
if (spanFactory == null) {
throw new IllegalStateException("Requested type `" + type.getName() + "` was " +
"not registered, use Builder#includeEditSpan method to register");
}
span = spanFactory.create();
}
//noinspection unchecked
return (T) span;
}
void removeUnused() {
for (List<Object> spans : map.values()) {
if (spans != null
&& spans.size() > 0) {
for (Object span : spans) {
spannable.removeSpan(span);
}
}
}
}
}
private static class Span {
final Object what;
final int start;

@ -129,26 +129,43 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher {
future = executorService.submit(new Runnable() {
@Override
public void run() {
editor.preRender(s, new MarkwonEditor.PreRenderResultListener() {
@Override
public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) {
if (editText != null) {
editText.post(new Runnable() {
@Override
public void run() {
if (key == generator) {
selfChange = true;
try {
result.dispatchTo(editText.getText());
} finally {
selfChange = false;
try {
editor.preRender(s, new MarkwonEditor.PreRenderResultListener() {
@Override
public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) {
final EditText et = editText;
if (et != null) {
et.post(new Runnable() {
@Override
public void run() {
if (key == generator) {
final EditText et = editText;
if (et != null) {
selfChange = true;
try {
result.dispatchTo(editText.getText());
} finally {
selfChange = false;
}
}
}
}
}
});
});
}
}
});
} catch (final Throwable t) {
final EditText et = editText;
if (et != null) {
// propagate exception to main thread
et.post(new Runnable() {
@Override
public void run() {
throw new RuntimeException(t);
}
});
}
});
}
}
});
}

@ -1,13 +1,44 @@
package io.noties.markwon.editor;
import android.text.Spanned;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @since 4.2.0-SNAPSHOT
*/
public abstract class MarkwonEditorUtils {
@NonNull
public static Map<Class<?>, List<Object>> extractSpans(@NonNull Spanned spanned, @NonNull Collection<Class<?>> types) {
final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
final Map<Class<?>, List<Object>> map = new HashMap<>(3);
Class<?> type;
for (Object span : spans) {
type = span.getClass();
if (types.contains(type)) {
List<Object> list = map.get(type);
if (list == null) {
list = new ArrayList<>(3);
map.put(type, list);
}
list.add(span);
}
}
return map;
}
public interface Match {
@NonNull

@ -0,0 +1,116 @@
package io.noties.markwon.editor;
import android.text.Editable;
import android.text.Spannable;
import android.util.Log;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static io.noties.markwon.editor.MarkwonEditorUtils.extractSpans;
/**
* Cache for spans that present in user input. These spans are reused between different
* {@link MarkwonEditor#process(Editable)} and {@link MarkwonEditor#preRender(Editable, MarkwonEditor.PreRenderResultListener)}
* calls.
*
* @see EditHandler#handleMarkdownSpan(PersistedSpans, Editable, String, Object, int, int)
* @see EditHandler#configurePersistedSpans(Builder)
* @since 4.2.0-SNAPSHOT
*/
public abstract class PersistedSpans {
public interface SpanFactory<T> {
@NonNull
T create();
}
public interface Builder {
@SuppressWarnings("UnusedReturnValue")
@NonNull
<T> Builder persistSpan(@NonNull Class<T> type, @NonNull SpanFactory<T> spanFactory);
}
@NonNull
public abstract <T> T get(@NonNull Class<T> type);
abstract void removeUnused();
@NonNull
static Provider provider() {
return new Provider();
}
static class Provider implements Builder {
private final Map<Class<?>, SpanFactory> map = new HashMap<>(3);
@NonNull
@Override
public <T> Builder persistSpan(@NonNull Class<T> type, @NonNull SpanFactory<T> spanFactory) {
if (map.put(type, spanFactory) != null) {
Log.e("MD-EDITOR", String.format(
Locale.ROOT,
"Re-declaration of persisted span for '%s'", type.getName()));
}
return this;
}
@NonNull
PersistedSpans provide(@NonNull Spannable spannable) {
return new Impl(spannable, map);
}
}
static class Impl extends PersistedSpans {
private final Spannable spannable;
private final Map<Class<?>, SpanFactory> spans;
private final Map<Class<?>, List<Object>> map;
Impl(@NonNull Spannable spannable, @NonNull Map<Class<?>, SpanFactory> spans) {
this.spannable = spannable;
this.spans = spans;
this.map = extractSpans(spannable, spans.keySet());
}
@NonNull
@Override
public <T> T get(@NonNull Class<T> type) {
final Object span;
final List<Object> list = map.get(type);
if (list != null && list.size() > 0) {
span = list.remove(0);
} else {
final SpanFactory spanFactory = spans.get(type);
if (spanFactory == null) {
throw new IllegalStateException("Requested type `" + type.getName() + "` was " +
"not registered, use PersistedSpans.Builder#persistSpan method to register");
}
span = spanFactory.create();
}
//noinspection unchecked
return (T) span;
}
@Override
void removeUnused() {
for (List<Object> spans : map.values()) {
if (spans != null
&& spans.size() > 0) {
for (Object span : spans) {
spannable.removeSpan(span);
}
}
}
}
}
}

@ -0,0 +1,54 @@
package io.noties.markwon.editor.handler;
import android.text.Editable;
import android.text.Spanned;
import androidx.annotation.NonNull;
import io.noties.markwon.core.spans.EmphasisSpan;
import io.noties.markwon.editor.AbstractEditHandler;
import io.noties.markwon.editor.MarkwonEditorUtils;
import io.noties.markwon.editor.PersistedSpans;
/**
* @since 4.2.0-SNAPSHOT
*/
public class EmphasisEditHandler extends AbstractEditHandler<EmphasisSpan> {
@Override
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
builder.persistSpan(EmphasisSpan.class, new PersistedSpans.SpanFactory<EmphasisSpan>() {
@NonNull
@Override
public EmphasisSpan create() {
return new EmphasisSpan();
}
});
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull EmphasisSpan span,
int spanStart,
int spanTextLength) {
final MarkwonEditorUtils.Match match =
MarkwonEditorUtils.findDelimited(input, spanStart, "*", "_");
if (match != null) {
editable.setSpan(
persistedSpans.get(EmphasisSpan.class),
match.start(),
match.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
}
@NonNull
@Override
public Class<EmphasisSpan> markdownSpanType() {
return EmphasisSpan.class;
}
}

@ -0,0 +1,62 @@
package io.noties.markwon.editor.handler;
import android.text.Editable;
import android.text.Spanned;
import androidx.annotation.NonNull;
import io.noties.markwon.core.spans.StrongEmphasisSpan;
import io.noties.markwon.editor.AbstractEditHandler;
import io.noties.markwon.editor.MarkwonEditorUtils;
import io.noties.markwon.editor.PersistedSpans;
/**
* @since 4.2.0-SNAPSHOT
*/
public class StrongEmphasisEditHandler extends AbstractEditHandler<StrongEmphasisSpan> {
@NonNull
public static StrongEmphasisEditHandler create() {
return new StrongEmphasisEditHandler();
}
@Override
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
builder.persistSpan(StrongEmphasisSpan.class, new PersistedSpans.SpanFactory<StrongEmphasisSpan>() {
@NonNull
@Override
public StrongEmphasisSpan create() {
return new StrongEmphasisSpan();
}
});
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull StrongEmphasisSpan span,
int spanStart,
int 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(
persistedSpans.get(StrongEmphasisSpan.class),
match.start(),
match.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
}
@NonNull
@Override
public Class<StrongEmphasisSpan> markdownSpanType() {
return StrongEmphasisSpan.class;
}
}

@ -1,9 +1,6 @@
package io.noties.markwon.editor;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import androidx.annotation.NonNull;
import org.junit.Test;
import org.junit.runner.RunWith;
@ -11,135 +8,14 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import io.noties.markwon.Markwon;
import io.noties.markwon.editor.MarkwonEditorImpl.EditSpanStoreImpl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class MarkwonEditorImplTest {
@Test
public void extract_spans() {
final class One {
}
final class Two {
}
final class Three {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
append(builder, "one", new One());
append(builder, "two", new Two(), new Two());
append(builder, "three", new Three(), new Three(), new Three());
final Map<Class<?>, List<Object>> map = MarkwonEditorImpl.extractSpans(
builder,
Arrays.asList(One.class, Three.class));
assertEquals(2, map.size());
assertNotNull(map.get(One.class));
assertNull(map.get(Two.class));
assertNotNull(map.get(Three.class));
//noinspection ConstantConditions
assertEquals(1, map.get(One.class).size());
//noinspection ConstantConditions
assertEquals(3, map.get(Three.class).size());
}
private static void append(@NonNull SpannableStringBuilder builder, @NonNull String text, Object... spans) {
final int start = builder.length();
builder.append(text);
final int end = builder.length();
for (Object span : spans) {
builder.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
@Test
public void edit_span_store_span_not_included() {
// When store is requesting a span that is not included -> exception is raised
final Map<Class<?>, MarkwonEditor.EditSpanFactory> map = Collections.emptyMap();
final EditSpanStoreImpl impl = new EditSpanStoreImpl(new SpannableStringBuilder(), map);
try {
impl.get(Object.class);
fail();
} catch (IllegalStateException e) {
assertTrue(e.getMessage(), e.getMessage().contains("not registered, use Builder#includeEditSpan method to register"));
}
}
@Test
public void edit_span_store_reuse() {
// when a span is present in supplied spannable -> it will be used
final class One {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
final One one = new One();
append(builder, "One", one);
final Map<Class<?>, MarkwonEditor.EditSpanFactory> map = new HashMap<Class<?>, MarkwonEditor.EditSpanFactory>() {{
// null in case it _will_ be used -> thus NPE
put(One.class, null);
}};
final EditSpanStoreImpl impl = new EditSpanStoreImpl(builder, map);
assertEquals(one, impl.get(One.class));
}
@Test
public void edit_span_store_factory_create() {
// when span is not present in spannable -> new one will be created via factory
final class Two {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
final Two two = new Two();
append(builder, "two", two);
final MarkwonEditor.EditSpanFactory factory = mock(MarkwonEditor.EditSpanFactory.class);
final Map<Class<?>, MarkwonEditor.EditSpanFactory> map = new HashMap<Class<?>, MarkwonEditor.EditSpanFactory>() {{
put(Two.class, factory);
}};
final EditSpanStoreImpl impl = new EditSpanStoreImpl(builder, map);
// first one will be the same as we had created before,
// second one will be created via factory
assertEquals(two, impl.get(Two.class));
verify(factory, never()).create();
impl.get(Two.class);
verify(factory, times(1)).create();
}
@Test
public void process() {
// create markwon

@ -27,19 +27,4 @@ public class MarkwonEditorTest {
fail(t.getMessage());
}
}
@Test
public void builder_with_edit_spans_but_no_handler() {
// if edit spans are specified, but no edit span handler is present -> exception is thrown
try {
//noinspection unchecked
new Builder(mock(Markwon.class))
.includeEditSpan(Object.class, mock(MarkwonEditor.EditSpanFactory.class))
.build();
fail();
} catch (IllegalStateException e) {
assertTrue(e.getMessage(), e.getMessage().contains("There is no need to include edit spans "));
}
}
}

@ -16,8 +16,11 @@ import java.util.concurrent.ExecutorService;
import io.noties.markwon.editor.MarkwonEditor.PreRenderResult;
import io.noties.markwon.editor.MarkwonEditor.PreRenderResultListener;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.RETURNS_MOCKS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
@ -88,7 +91,49 @@ public class MarkwonEditorTextWatcherTest {
listener.onPreRenderResult(result);
verify(result, times(1)).resultEditable();
// if we would check for hashCode then this method would've been invoked
// verify(result, times(1)).resultEditable();
verify(result, times(1)).dispatchTo(eq(editable));
}
@Test
public void pre_render_posts_exception_to_main_thread() {
final RuntimeException e = new RuntimeException();
final MarkwonEditor editor = mock(MarkwonEditor.class);
final ExecutorService service = mock(ExecutorService.class);
final EditText editText = mock(EditText.class, RETURNS_MOCKS);
doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) {
throw e;
}
}).when(editor).preRender(any(Editable.class), any(PreRenderResultListener.class));
when(service.submit(any(Runnable.class))).thenAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) {
((Runnable) invocation.getArgument(0)).run();
return null;
}
});
final ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
final MarkwonEditorTextWatcher textWatcher =
MarkwonEditorTextWatcher.withPreRender(editor, service, editText);
textWatcher.afterTextChanged(mock(Editable.class));
verify(editText, times(1)).post(captor.capture());
try {
captor.getValue().run();
fail();
} catch (Throwable t) {
assertEquals(e, t.getCause());
}
}
}

@ -1,5 +1,7 @@
package io.noties.markwon.editor;
import android.text.SpannableStringBuilder;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -8,19 +10,55 @@ import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import io.noties.markwon.editor.MarkwonEditorUtils.Match;
import static io.noties.markwon.editor.MarkwonEditorUtils.findDelimited;
import static io.noties.markwon.editor.SpannableUtils.append;
import static java.lang.String.format;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class MarkwonEditorUtilsTest {
@Test
public void extract_spans() {
final class One {
}
final class Two {
}
final class Three {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
append(builder, "one", new One());
append(builder, "two", new Two(), new Two());
append(builder, "three", new Three(), new Three(), new Three());
final Map<Class<?>, List<Object>> map = MarkwonEditorUtils.extractSpans(
builder,
Arrays.asList(One.class, Three.class));
assertEquals(2, map.size());
assertNotNull(map.get(One.class));
assertNull(map.get(Two.class));
assertNotNull(map.get(Three.class));
//noinspection ConstantConditions
assertEquals(1, map.get(One.class).size());
//noinspection ConstantConditions
assertEquals(3, map.get(Three.class).size());
}
@Test
public void delimited_single() {
final String input = "**bold**";

@ -0,0 +1,96 @@
package io.noties.markwon.editor;
import android.text.SpannableStringBuilder;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import io.noties.markwon.editor.PersistedSpans.Impl;
import io.noties.markwon.editor.PersistedSpans.SpanFactory;
import static io.noties.markwon.editor.SpannableUtils.append;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class PersistedSpansTest {
@Test
public void not_included() {
// When a span that is not included is requested -> exception is raised
final Map<Class<?>, SpanFactory> map = Collections.emptyMap();
final Impl impl = new Impl(new SpannableStringBuilder(), map);
try {
impl.get(Object.class);
fail();
} catch (IllegalStateException e) {
assertTrue(e.getMessage(), e.getMessage().contains("not registered, use PersistedSpans.Builder#persistSpan method to register"));
}
}
@Test
public void re_use() {
// when a span is present in supplied spannable -> it will be used
final class One {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
final One one = new One();
append(builder, "One", one);
final Map<Class<?>, SpanFactory> map = new HashMap<Class<?>, SpanFactory>() {{
// null in case it _will_ be used -> thus NPE
put(One.class, null);
}};
final Impl impl = new Impl(builder, map);
assertEquals(one, impl.get(One.class));
}
@Test
public void factory_create() {
// when span is not present in spannable -> new one will be created via factory
final class Two {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
final Two two = new Two();
append(builder, "two", two);
final SpanFactory factory = mock(SpanFactory.class);
final Map<Class<?>, SpanFactory> map = new HashMap<Class<?>, SpanFactory>() {{
put(Two.class, factory);
}};
final Impl impl = new Impl(builder, map);
// first one will be the same as we had created before,
// second one will be created via factory
assertEquals(two, impl.get(Two.class));
verify(factory, never()).create();
impl.get(Two.class);
verify(factory, times(1)).create();
}
}

@ -0,0 +1,21 @@
package io.noties.markwon.editor;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import androidx.annotation.NonNull;
abstract class SpannableUtils {
static void append(@NonNull SpannableStringBuilder builder, @NonNull String text, Object... spans) {
final int start = builder.length();
builder.append(text);
final int end = builder.length();
for (Object span : spans) {
builder.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
private SpannableUtils() {
}
}

@ -0,0 +1,50 @@
package io.noties.markwon.sample.editor;
import android.text.Editable;
import android.text.Spanned;
import androidx.annotation.NonNull;
import io.noties.markwon.Markwon;
import io.noties.markwon.core.MarkwonTheme;
import io.noties.markwon.core.spans.BlockQuoteSpan;
import io.noties.markwon.editor.EditHandler;
import io.noties.markwon.editor.PersistedSpans;
class BlockQuoteEditHandler implements EditHandler<BlockQuoteSpan> {
private MarkwonTheme theme;
@Override
public void init(@NonNull Markwon markwon) {
this.theme = markwon.configuration().theme();
}
@Override
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
builder.persistSpan(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme));
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull BlockQuoteSpan span,
int spanStart,
int spanTextLength) {
// todo: here we should actually find a proper ending of a block quote...
editable.setSpan(
persistedSpans.get(BlockQuoteSpan.class),
spanStart,
spanStart + spanTextLength,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
@NonNull
@Override
public Class<BlockQuoteSpan> markdownSpanType() {
return BlockQuoteSpan.class;
}
}

@ -0,0 +1,54 @@
package io.noties.markwon.sample.editor;
import android.text.Editable;
import android.text.Spanned;
import androidx.annotation.NonNull;
import io.noties.markwon.Markwon;
import io.noties.markwon.core.MarkwonTheme;
import io.noties.markwon.core.spans.CodeSpan;
import io.noties.markwon.editor.EditHandler;
import io.noties.markwon.editor.MarkwonEditorUtils;
import io.noties.markwon.editor.PersistedSpans;
class CodeEditHandler implements EditHandler<CodeSpan> {
private MarkwonTheme theme;
@Override
public void init(@NonNull Markwon markwon) {
this.theme = markwon.configuration().theme();
}
@Override
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
builder.persistSpan(CodeSpan.class, () -> new CodeSpan(theme));
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull CodeSpan span,
int spanStart,
int spanTextLength) {
final MarkwonEditorUtils.Match match =
MarkwonEditorUtils.findDelimited(input, spanStart, "`");
if (match != null) {
editable.setSpan(
persistedSpans.get(CodeSpan.class),
match.start(),
match.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
}
@NonNull
@Override
public Class<CodeSpan> markdownSpanType() {
return CodeSpan.class;
}
}

@ -7,7 +7,6 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
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;
@ -19,21 +18,23 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.commonmark.parser.Parser;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.Markwon;
import io.noties.markwon.core.MarkwonTheme;
import io.noties.markwon.core.spans.BlockQuoteSpan;
import io.noties.markwon.core.spans.CodeSpan;
import io.noties.markwon.core.spans.EmphasisSpan;
import io.noties.markwon.core.spans.LinkSpan;
import io.noties.markwon.core.spans.StrongEmphasisSpan;
import io.noties.markwon.editor.EditSpanHandlerBuilder;
import io.noties.markwon.editor.AbstractEditHandler;
import io.noties.markwon.editor.MarkwonEditor;
import io.noties.markwon.editor.MarkwonEditorTextWatcher;
import io.noties.markwon.editor.MarkwonEditorUtils;
import io.noties.markwon.editor.PersistedSpans;
import io.noties.markwon.editor.handler.EmphasisEditHandler;
import io.noties.markwon.editor.handler.StrongEmphasisEditHandler;
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.noties.markwon.sample.R;
@ -92,7 +93,7 @@ public class EditorActivity extends Activity {
// Use own punctuation span
final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this))
.withPunctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new)
.punctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new)
.build();
editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
@ -102,32 +103,46 @@ public class EditorActivity extends Activity {
// An additional span is used to highlight strong-emphasis
final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this))
// This is required for edit-span cache
// We could use Markwon `StrongEmphasisSpan` here, but I use a different
// one to indicate that those are completely unrelated spans and must be
// treated differently.
.includeEditSpan(Bold.class, Bold::new)
.withEditSpanHandler(new MarkwonEditor.EditSpanHandler() {
.useEditHandler(new AbstractEditHandler<StrongEmphasisSpan>() {
@Override
public void handle(
@NonNull MarkwonEditor.EditSpanStore store,
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
// Here we define which span is _persisted_ in EditText, it is not removed
// from EditText between text changes, but instead - reused (by changing
// position). Consider it as a cache for spans. We could use `StrongEmphasisSpan`
// here also, but I chose Bold to indicate that this span is not the same
// as in off-screen rendered markdown
builder.persistSpan(Bold.class, Bold::new);
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull Object span,
@NonNull StrongEmphasisSpan span,
int spanStart,
int spanTextLength) {
if (span instanceof StrongEmphasisSpan) {
// Unfortunately we cannot hardcode delimiters length here (aka spanTextLength + 4)
// because multiple inline markdown nodes can refer to the same text.
// For example, `**_~~hey~~_**` - we will receive `**_~~` in this method,
// and thus will have to manually find actual position in raw user input
final MarkwonEditorUtils.Match match =
MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__");
if (match != null) {
editable.setSpan(
// `includeEditSpan(Bold.class, Bold::new)` ensured that we have
// a span here to use (either reuse existing or create a new one)
store.get(Bold.class),
spanStart,
// we know that strong emphasis is delimited with 2 characters on both sides
spanStart + spanTextLength + 4,
persistedSpans.get(Bold.class),
match.start(),
match.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
}
@NonNull
@Override
public Class<StrongEmphasisSpan> markdownSpanType() {
return StrongEmphasisSpan.class;
}
})
.build();
@ -156,20 +171,24 @@ public class EditorActivity extends Activity {
final Markwon markwon = Markwon.builder(this)
.usePlugin(StrikethroughPlugin.create())
.usePlugin(LinkifyPlugin.create())
.usePlugin(new AbstractMarkwonPlugin() {
@Override
public void configureParser(@NonNull Parser.Builder builder) {
// disable all commonmark-java blocks, only inlines will be parsed
// builder.enabledBlockTypes(Collections.emptySet());
}
})
.build();
final MarkwonTheme theme = markwon.configuration().theme();
final EditLinkSpan.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link);
final LinkEditHandler.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(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme))
.includeEditSpan(EditLinkSpan.class, () -> new EditLinkSpan(onClick))
.withEditSpanHandler(createEditSpanHandler())
.useEditHandler(new EmphasisEditHandler())
.useEditHandler(new StrongEmphasisEditHandler())
.useEditHandler(new StrikethroughEditHandler())
.useEditHandler(new CodeEditHandler())
.useEditHandler(new BlockQuoteEditHandler())
.useEditHandler(new LinkEditHandler(onClick))
.build();
// editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
@ -177,100 +196,6 @@ public class EditorActivity extends Activity {
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) -> {
// 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) -> {
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) -> {
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
final MarkwonEditorUtils.Match match =
MarkwonEditorUtils.findDelimited(input, spanStart, "`");
if (match != null) {
editable.setSpan(
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,
spanStart + spanTextLength,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
})
.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(
editLinkSpan,
// add underline only for link text
s,
e,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
})
// returns nullable type
.build();
}
private void initBottomBar() {
// all except block-quote wraps if have selection, or inserts at current cursor position
@ -382,26 +307,4 @@ public class EditorActivity extends Activity {
paint.setFakeBoldText(true);
}
}
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 onClick(@NonNull View widget) {
if (link != null) {
onClick.onClick(widget, link);
}
}
}
}

@ -0,0 +1,86 @@
package io.noties.markwon.sample.editor;
import android.text.Editable;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.view.View;
import androidx.annotation.NonNull;
import io.noties.markwon.core.spans.LinkSpan;
import io.noties.markwon.editor.AbstractEditHandler;
import io.noties.markwon.editor.PersistedSpans;
class LinkEditHandler extends AbstractEditHandler<LinkSpan> {
interface OnClick {
void onClick(@NonNull View widget, @NonNull String link);
}
private final OnClick onClick;
LinkEditHandler(@NonNull OnClick onClick) {
this.onClick = onClick;
}
@Override
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
builder.persistSpan(EditLinkSpan.class, () -> new EditLinkSpan(onClick));
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull LinkSpan span,
int spanStart,
int spanTextLength) {
final EditLinkSpan editLinkSpan = persistedSpans.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(
editLinkSpan,
s,
e,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
@NonNull
@Override
public Class<LinkSpan> markdownSpanType() {
return LinkSpan.class;
}
static class EditLinkSpan extends ClickableSpan {
private final OnClick onClick;
String link;
EditLinkSpan(@NonNull OnClick onClick) {
this.onClick = onClick;
}
@Override
public void onClick(@NonNull View widget) {
if (link != null) {
onClick.onClick(widget, link);
}
}
}
}

@ -0,0 +1,45 @@
package io.noties.markwon.sample.editor;
import android.text.Editable;
import android.text.Spanned;
import android.text.style.StrikethroughSpan;
import androidx.annotation.NonNull;
import io.noties.markwon.editor.AbstractEditHandler;
import io.noties.markwon.editor.MarkwonEditorUtils;
import io.noties.markwon.editor.PersistedSpans;
class StrikethroughEditHandler extends AbstractEditHandler<StrikethroughSpan> {
@Override
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
builder.persistSpan(StrikethroughSpan.class, StrikethroughSpan::new);
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull StrikethroughSpan span,
int spanStart,
int spanTextLength) {
final MarkwonEditorUtils.Match match =
MarkwonEditorUtils.findDelimited(input, spanStart, "~~");
if (match != null) {
editable.setSpan(
persistedSpans.get(StrikethroughSpan.class),
match.start(),
match.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
}
@NonNull
@Override
public Class<StrikethroughSpan> markdownSpanType() {
return StrikethroughSpan.class;
}
}