diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/AbstractEditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/AbstractEditHandler.java new file mode 100644 index 00000000..00db0e24 --- /dev/null +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/AbstractEditHandler.java @@ -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 implements EditHandler { + @Override + public void init(@NonNull Markwon markwon) { + + } +} diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/EditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/EditHandler.java new file mode 100644 index 00000000..d9a1c756 --- /dev/null +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/EditHandler.java @@ -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 { + + 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 markdownSpanType(); +} diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/EditSpanHandlerBuilder.java b/markwon-editor/src/main/java/io/noties/markwon/editor/EditSpanHandlerBuilder.java deleted file mode 100644 index 96c47424..00000000 --- a/markwon-editor/src/main/java/io/noties/markwon/editor/EditSpanHandlerBuilder.java +++ /dev/null @@ -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 { - 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, EditSpanHandlerTyped> map = new HashMap<>(3); - - @NonNull - public EditSpanHandlerBuilder handleMarkdownSpan( - @NonNull Class type, - @NonNull EditSpanHandlerTyped 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, EditSpanHandlerTyped> map; - - EditSpanHandlerImpl(@NonNull Map, 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); - } - } - } -} diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java index e6e0b185..bea9f684 100644 --- a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java @@ -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 get(Class type); - } - - public interface EditSpanFactory { - @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. *

* 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, EditHandler> editHandlers = new HashMap<>(0); private Class punctuationSpanType; - private Map, EditSpanFactory> spans = new HashMap<>(3); - private EditSpanHandler editSpanHandler; Builder(@NonNull Markwon markwon) { this.markwon = markwon; } + @NonNull + public Builder useEditHandler(@NonNull EditHandler 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 Builder withPunctuationSpan(@NonNull Class type, @NonNull EditSpanFactory factory) { + public Builder punctuationSpan(@NonNull Class type, @NonNull PersistedSpans.SpanFactory 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. - *

- * 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 Builder includeEditSpan( - @NonNull Class type, - @NonNull EditSpanFactory 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.class, new PersistedSpans.SpanFactory() { @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, EditHandler> spanHandlers; + + SpansHandlerImpl(@NonNull Map, 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); + } } } } 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 32c56e63..22783da5 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 @@ -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, 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, 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 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, List> extractSpans(@NonNull Spanned spanned, @NonNull Collection> types) { - - final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); - final Map, List> map = new HashMap<>(3); - - Class type; - - for (Object span : spans) { - type = span.getClass(); - if (types.contains(type)) { - List 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, EditSpanFactory> spans; - private final Map, List> map; - - EditSpanStoreImpl(@NonNull Spannable spannable, @NonNull Map, EditSpanFactory> spans) { - this.spannable = spannable; - this.spans = spans; - this.map = extractSpans(spannable, spans.keySet()); - } - - @NonNull - @Override - public T get(Class type) { - - final Object span; - - final List 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 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; 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 5d7acd26..12dc9e00 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 @@ -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); + } + }); } - }); + } } }); } 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 index d2824bdb..6471c624 100644 --- a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java @@ -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, List> extractSpans(@NonNull Spanned spanned, @NonNull Collection> types) { + + final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); + final Map, List> map = new HashMap<>(3); + + Class type; + + for (Object span : spans) { + type = span.getClass(); + if (types.contains(type)) { + List list = map.get(type); + if (list == null) { + list = new ArrayList<>(3); + map.put(type, list); + } + list.add(span); + } + } + + return map; + } + public interface Match { @NonNull diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/PersistedSpans.java b/markwon-editor/src/main/java/io/noties/markwon/editor/PersistedSpans.java new file mode 100644 index 00000000..1d4bf88d --- /dev/null +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/PersistedSpans.java @@ -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 { + @NonNull + T create(); + } + + public interface Builder { + @SuppressWarnings("UnusedReturnValue") + @NonNull + Builder persistSpan(@NonNull Class type, @NonNull SpanFactory spanFactory); + } + + @NonNull + public abstract T get(@NonNull Class type); + + abstract void removeUnused(); + + + @NonNull + static Provider provider() { + return new Provider(); + } + + static class Provider implements Builder { + + private final Map, SpanFactory> map = new HashMap<>(3); + + @NonNull + @Override + public Builder persistSpan(@NonNull Class type, @NonNull SpanFactory 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, SpanFactory> spans; + private final Map, List> map; + + Impl(@NonNull Spannable spannable, @NonNull Map, SpanFactory> spans) { + this.spannable = spannable; + this.spans = spans; + this.map = extractSpans(spannable, spans.keySet()); + } + + @NonNull + @Override + public T get(@NonNull Class type) { + + final Object span; + + final List 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 spans : map.values()) { + if (spans != null + && spans.size() > 0) { + for (Object span : spans) { + spannable.removeSpan(span); + } + } + } + } + } +} diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/handler/EmphasisEditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/EmphasisEditHandler.java new file mode 100644 index 00000000..ca7044b2 --- /dev/null +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/EmphasisEditHandler.java @@ -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 { + + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + builder.persistSpan(EmphasisSpan.class, new PersistedSpans.SpanFactory() { + @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 markdownSpanType() { + return EmphasisSpan.class; + } +} diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/handler/StrongEmphasisEditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/StrongEmphasisEditHandler.java new file mode 100644 index 00000000..90956c50 --- /dev/null +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/StrongEmphasisEditHandler.java @@ -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 { + + @NonNull + public static StrongEmphasisEditHandler create() { + return new StrongEmphasisEditHandler(); + } + + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + builder.persistSpan(StrongEmphasisSpan.class, new PersistedSpans.SpanFactory() { + @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 markdownSpanType() { + return StrongEmphasisSpan.class; + } +} diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java index 9bbfc829..bc6422c9 100644 --- a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java @@ -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, List> 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, 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, MarkwonEditor.EditSpanFactory> map = new HashMap, 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, MarkwonEditor.EditSpanFactory> map = new HashMap, 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 diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java index b177a143..8554d2bb 100644 --- a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java @@ -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 ")); - } - } } \ No newline at end of file diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java index 9d144cdc..0befcadc 100644 --- a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java @@ -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() { + @Override + public Object answer(InvocationOnMock invocation) { + ((Runnable) invocation.getArgument(0)).run(); + return null; + } + }); + + final ArgumentCaptor 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()); + } + } } 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 index d53f1cb4..2aa56b95 100644 --- a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java @@ -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, List> 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**"; diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/PersistedSpansTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/PersistedSpansTest.java new file mode 100644 index 00000000..36be6ce7 --- /dev/null +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/PersistedSpansTest.java @@ -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, 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, SpanFactory> map = new HashMap, 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, SpanFactory> map = new HashMap, 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(); + } +} \ No newline at end of file diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/SpannableUtils.java b/markwon-editor/src/test/java/io/noties/markwon/editor/SpannableUtils.java new file mode 100644 index 00000000..858a239e --- /dev/null +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/SpannableUtils.java @@ -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() { + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/BlockQuoteEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/BlockQuoteEditHandler.java new file mode 100644 index 00000000..704d40e3 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/editor/BlockQuoteEditHandler.java @@ -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 { + + 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 markdownSpanType() { + return BlockQuoteSpan.class; + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/CodeEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/CodeEditHandler.java new file mode 100644 index 00000000..c54e1a77 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/editor/CodeEditHandler.java @@ -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 { + + 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 markdownSpanType() { + return CodeSpan.class; + } +} 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 14d8a802..d623d128 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 @@ -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() { @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 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); - } - } - } } diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java new file mode 100644 index 00000000..743428d0 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java @@ -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 { + + 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 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); + } + } + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/StrikethroughEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/StrikethroughEditHandler.java new file mode 100644 index 00000000..bffda27b --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/editor/StrikethroughEditHandler.java @@ -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 { + + @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 markdownSpanType() { + return StrikethroughSpan.class; + } +}