Stabilizing editor API
This commit is contained in:
		
							parent
							
								
									a6201b1b35
								
							
						
					
					
						commit
						c6fd779f33
					
				| @ -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 android.text.Editable; | ||||||
| 
 | 
 | ||||||
| import androidx.annotation.NonNull; | import androidx.annotation.NonNull; | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| 
 | 
 | ||||||
| import java.util.HashMap; | import java.util.HashMap; | ||||||
| import java.util.Map; | import java.util.Map; | ||||||
| @ -20,44 +19,8 @@ import io.noties.markwon.Markwon; | |||||||
| public abstract class MarkwonEditor { | 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 { |     public interface PreRenderResult { | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
| @ -116,7 +79,7 @@ public abstract class MarkwonEditor { | |||||||
|      * thread. |      * thread. | ||||||
|      * <p> |      * <p> | ||||||
|      * Please note that currently only `setSpan` and `removeSpan` actions will be recorded (and thus dispatched). |      * 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. |      * functionality some other way. | ||||||
|      * |      * | ||||||
|      * @param editable          to process and pre-render |      * @param editable          to process and pre-render | ||||||
| @ -129,15 +92,22 @@ public abstract class MarkwonEditor { | |||||||
|     public static class Builder { |     public static class Builder { | ||||||
| 
 | 
 | ||||||
|         private final Markwon markwon; |         private final Markwon markwon; | ||||||
|  |         private final PersistedSpans.Provider persistedSpansProvider = PersistedSpans.provider(); | ||||||
|  |         private final Map<Class<?>, EditHandler> editHandlers = new HashMap<>(0); | ||||||
| 
 | 
 | ||||||
|         private Class<?> punctuationSpanType; |         private Class<?> punctuationSpanType; | ||||||
|         private Map<Class<?>, EditSpanFactory> spans = new HashMap<>(3); |  | ||||||
|         private EditSpanHandler editSpanHandler; |  | ||||||
| 
 | 
 | ||||||
|         Builder(@NonNull Markwon markwon) { |         Builder(@NonNull Markwon markwon) { | ||||||
|             this.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. |          * 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 |          * @param factory to create a new instance of the span | ||||||
|          */ |          */ | ||||||
|         @NonNull |         @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.punctuationSpanType = type; | ||||||
|             this.spans.put(type, factory); |             this.persistedSpansProvider.persistSpan(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; |  | ||||||
|             return this; |             return this; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
| @ -190,7 +126,7 @@ public abstract class MarkwonEditor { | |||||||
| 
 | 
 | ||||||
|             Class<?> punctuationSpanType = this.punctuationSpanType; |             Class<?> punctuationSpanType = this.punctuationSpanType; | ||||||
|             if (punctuationSpanType == null) { |             if (punctuationSpanType == null) { | ||||||
|                 withPunctuationSpan(PunctuationSpan.class, new EditSpanFactory<PunctuationSpan>() { |                 punctuationSpan(PunctuationSpan.class, new PersistedSpans.SpanFactory<PunctuationSpan>() { | ||||||
|                     @NonNull |                     @NonNull | ||||||
|                     @Override |                     @Override | ||||||
|                     public PunctuationSpan create() { |                     public PunctuationSpan create() { | ||||||
| @ -200,17 +136,54 @@ public abstract class MarkwonEditor { | |||||||
|                 punctuationSpanType = this.punctuationSpanType; |                 punctuationSpanType = this.punctuationSpanType; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             // if we have no editSpanHandler, but spans are registered -> throw an error |             for (EditHandler handler : editHandlers.values()) { | ||||||
|             if (spans.size() > 1 && editSpanHandler == null) { |                 handler.init(markwon); | ||||||
|                 throw new IllegalStateException("There is no need to include edit spans " + |                 handler.configurePersistedSpans(persistedSpansProvider); | ||||||
|                         "when you do not use custom EditSpanHandler"); |  | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|  |             final SpansHandler spansHandler = editHandlers.size() == 0 | ||||||
|  |                     ? null | ||||||
|  |                     : new SpansHandlerImpl(editHandlers); | ||||||
|  | 
 | ||||||
|             return new MarkwonEditorImpl( |             return new MarkwonEditorImpl( | ||||||
|                     markwon, |                     markwon, | ||||||
|                     spans, |                     persistedSpansProvider, | ||||||
|                     punctuationSpanType, |                     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 androidx.annotation.Nullable; | ||||||
| 
 | 
 | ||||||
| import java.util.ArrayList; | import java.util.ArrayList; | ||||||
| import java.util.Collection; |  | ||||||
| import java.util.HashMap; |  | ||||||
| import java.util.List; | import java.util.List; | ||||||
| import java.util.Map; |  | ||||||
| 
 | 
 | ||||||
| import io.noties.markwon.Markwon; | import io.noties.markwon.Markwon; | ||||||
| import io.noties.markwon.editor.diff_match_patch.Diff; | 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 { | class MarkwonEditorImpl extends MarkwonEditor { | ||||||
| 
 | 
 | ||||||
|     private final Markwon markwon; |     private final Markwon markwon; | ||||||
|     private final Map<Class<?>, EditSpanFactory> spans; |     private final PersistedSpans.Provider persistedSpansProvider; | ||||||
|     private final Class<?> punctuationSpanType; |     private final Class<?> punctuationSpanType; | ||||||
| 
 | 
 | ||||||
|     @Nullable |     @Nullable | ||||||
|     private final EditSpanHandler editSpanHandler; |     private final SpansHandler spansHandler; | ||||||
| 
 | 
 | ||||||
|     MarkwonEditorImpl( |     MarkwonEditorImpl( | ||||||
|             @NonNull Markwon markwon, |             @NonNull Markwon markwon, | ||||||
|             @NonNull Map<Class<?>, EditSpanFactory> spans, |             @NonNull PersistedSpans.Provider persistedSpansProvider, | ||||||
|             @NonNull Class<?> punctuationSpanType, |             @NonNull Class<?> punctuationSpanType, | ||||||
|             @Nullable EditSpanHandler editSpanHandler) { |             @Nullable SpansHandler spansHandler) { | ||||||
|         this.markwon = markwon; |         this.markwon = markwon; | ||||||
|         this.spans = spans; |         this.persistedSpansProvider = persistedSpansProvider; | ||||||
|         this.punctuationSpanType = punctuationSpanType; |         this.punctuationSpanType = punctuationSpanType; | ||||||
|         this.editSpanHandler = editSpanHandler; |         this.spansHandler = spansHandler; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Override |     @Override | ||||||
| @ -49,10 +46,10 @@ class MarkwonEditorImpl extends MarkwonEditor { | |||||||
| 
 | 
 | ||||||
|         final String markdown = renderedMarkdown.toString(); |         final String markdown = renderedMarkdown.toString(); | ||||||
| 
 | 
 | ||||||
|         final EditSpanHandler editSpanHandler = this.editSpanHandler; |         final SpansHandler spansHandler = this.spansHandler; | ||||||
|         final boolean hasAdditionalSpans = editSpanHandler != null; |         final boolean hasAdditionalSpans = spansHandler != null; | ||||||
| 
 | 
 | ||||||
|         final EditSpanStoreImpl store = new EditSpanStoreImpl(editable, spans); |         final PersistedSpans persistedSpans = persistedSpansProvider.provide(editable); | ||||||
|         try { |         try { | ||||||
| 
 | 
 | ||||||
|             final List<Diff> diffs = diff_match_patch.diff_main(input, markdown); |             final List<Diff> diffs = diff_match_patch.diff_main(input, markdown); | ||||||
| @ -68,8 +65,9 @@ class MarkwonEditorImpl extends MarkwonEditor { | |||||||
| 
 | 
 | ||||||
|                         final int start = inputLength; |                         final int start = inputLength; | ||||||
|                         inputLength += diff.text.length(); |                         inputLength += diff.text.length(); | ||||||
|  | 
 | ||||||
|                         editable.setSpan( |                         editable.setSpan( | ||||||
|                                 store.get(punctuationSpanType), |                                 persistedSpans.get(punctuationSpanType), | ||||||
|                                 start, |                                 start, | ||||||
|                                 inputLength, |                                 inputLength, | ||||||
|                                 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE |                                 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ||||||
| @ -84,8 +82,8 @@ class MarkwonEditorImpl extends MarkwonEditor { | |||||||
|                             for (Object span : spans) { |                             for (Object span : spans) { | ||||||
|                                 if (markdownLength == renderedMarkdown.getSpanStart(span)) { |                                 if (markdownLength == renderedMarkdown.getSpanStart(span)) { | ||||||
| 
 | 
 | ||||||
|                                     editSpanHandler.handle( |                                     spansHandler.handle( | ||||||
|                                             store, |                                             persistedSpans, | ||||||
|                                             editable, |                                             editable, | ||||||
|                                             input, |                                             input, | ||||||
|                                             span, |                                             span, | ||||||
| @ -126,8 +124,8 @@ class MarkwonEditorImpl extends MarkwonEditor { | |||||||
|                                     final int end = renderedMarkdown.getSpanEnd(span); |                                     final int end = renderedMarkdown.getSpanEnd(span); | ||||||
|                                     if (end <= markdownLength) { |                                     if (end <= markdownLength) { | ||||||
| 
 | 
 | ||||||
|                                         editSpanHandler.handle( |                                         spansHandler.handle( | ||||||
|                                                 store, |                                                 persistedSpans, | ||||||
|                                                 editable, |                                                 editable, | ||||||
|                                                 input, |                                                 input, | ||||||
|                                                 span, |                                                 span, | ||||||
| @ -149,7 +147,7 @@ class MarkwonEditorImpl extends MarkwonEditor { | |||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|         } finally { |         } 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 { |     private static class Span { | ||||||
|         final Object what; |         final Object what; | ||||||
|         final int start; |         final int start; | ||||||
|  | |||||||
| @ -129,14 +129,18 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher { | |||||||
|             future = executorService.submit(new Runnable() { |             future = executorService.submit(new Runnable() { | ||||||
|                 @Override |                 @Override | ||||||
|                 public void run() { |                 public void run() { | ||||||
|  |                     try { | ||||||
|                         editor.preRender(s, new MarkwonEditor.PreRenderResultListener() { |                         editor.preRender(s, new MarkwonEditor.PreRenderResultListener() { | ||||||
|                             @Override |                             @Override | ||||||
|                             public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) { |                             public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) { | ||||||
|                             if (editText != null) { |                                 final EditText et = editText; | ||||||
|                                 editText.post(new Runnable() { |                                 if (et != null) { | ||||||
|  |                                     et.post(new Runnable() { | ||||||
|                                         @Override |                                         @Override | ||||||
|                                         public void run() { |                                         public void run() { | ||||||
|                                             if (key == generator) { |                                             if (key == generator) { | ||||||
|  |                                                 final EditText et = editText; | ||||||
|  |                                                 if (et != null) { | ||||||
|                                                     selfChange = true; |                                                     selfChange = true; | ||||||
|                                                     try { |                                                     try { | ||||||
|                                                         result.dispatchTo(editText.getText()); |                                                         result.dispatchTo(editText.getText()); | ||||||
| @ -145,10 +149,23 @@ public abstract class MarkwonEditorTextWatcher implements TextWatcher { | |||||||
|                                                     } |                                                     } | ||||||
|                                                 } |                                                 } | ||||||
|                                             } |                                             } | ||||||
|  |                                         } | ||||||
|                                     }); |                                     }); | ||||||
|                                 } |                                 } | ||||||
|                             } |                             } | ||||||
|                         }); |                         }); | ||||||
|  |                     } 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; | package io.noties.markwon.editor; | ||||||
| 
 | 
 | ||||||
|  | import android.text.Spanned; | ||||||
|  | 
 | ||||||
| import androidx.annotation.NonNull; | import androidx.annotation.NonNull; | ||||||
| import androidx.annotation.Nullable; | 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 |  * @since 4.2.0-SNAPSHOT | ||||||
|  */ |  */ | ||||||
| public abstract class MarkwonEditorUtils { | 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 { |     public interface Match { | ||||||
| 
 | 
 | ||||||
|         @NonNull |         @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; | package io.noties.markwon.editor; | ||||||
| 
 | 
 | ||||||
| import android.text.SpannableStringBuilder; | import android.text.SpannableStringBuilder; | ||||||
| import android.text.Spanned; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| 
 | 
 | ||||||
| import org.junit.Test; | import org.junit.Test; | ||||||
| import org.junit.runner.RunWith; | import org.junit.runner.RunWith; | ||||||
| @ -11,135 +8,14 @@ import org.robolectric.RobolectricTestRunner; | |||||||
| import org.robolectric.RuntimeEnvironment; | import org.robolectric.RuntimeEnvironment; | ||||||
| import org.robolectric.annotation.Config; | 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.Markwon; | ||||||
| import io.noties.markwon.editor.MarkwonEditorImpl.EditSpanStoreImpl; |  | ||||||
| 
 | 
 | ||||||
| import static org.junit.Assert.assertEquals; | 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) | @RunWith(RobolectricTestRunner.class) | ||||||
| @Config(manifest = Config.NONE) | @Config(manifest = Config.NONE) | ||||||
| public class MarkwonEditorImplTest { | 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 |     @Test | ||||||
|     public void process() { |     public void process() { | ||||||
|         // create markwon |         // create markwon | ||||||
|  | |||||||
| @ -27,19 +27,4 @@ public class MarkwonEditorTest { | |||||||
|             fail(t.getMessage()); |             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.PreRenderResult; | ||||||
| import io.noties.markwon.editor.MarkwonEditor.PreRenderResultListener; | 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.any; | ||||||
| import static org.mockito.ArgumentMatchers.eq; | import static org.mockito.ArgumentMatchers.eq; | ||||||
|  | import static org.mockito.Mockito.RETURNS_MOCKS; | ||||||
| import static org.mockito.Mockito.doAnswer; | import static org.mockito.Mockito.doAnswer; | ||||||
| import static org.mockito.Mockito.mock; | import static org.mockito.Mockito.mock; | ||||||
| import static org.mockito.Mockito.times; | import static org.mockito.Mockito.times; | ||||||
| @ -88,7 +91,49 @@ public class MarkwonEditorTextWatcherTest { | |||||||
| 
 | 
 | ||||||
|         listener.onPreRenderResult(result); |         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)); |         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; | package io.noties.markwon.editor; | ||||||
| 
 | 
 | ||||||
|  | import android.text.SpannableStringBuilder; | ||||||
|  | 
 | ||||||
| import androidx.annotation.NonNull; | import androidx.annotation.NonNull; | ||||||
| import androidx.annotation.Nullable; | import androidx.annotation.Nullable; | ||||||
| 
 | 
 | ||||||
| @ -8,19 +10,55 @@ import org.junit.runner.RunWith; | |||||||
| import org.robolectric.RobolectricTestRunner; | import org.robolectric.RobolectricTestRunner; | ||||||
| import org.robolectric.annotation.Config; | import org.robolectric.annotation.Config; | ||||||
| 
 | 
 | ||||||
|  | import java.util.Arrays; | ||||||
|  | import java.util.List; | ||||||
| import java.util.Locale; | import java.util.Locale; | ||||||
|  | import java.util.Map; | ||||||
| 
 | 
 | ||||||
| import io.noties.markwon.editor.MarkwonEditorUtils.Match; | import io.noties.markwon.editor.MarkwonEditorUtils.Match; | ||||||
| 
 | 
 | ||||||
| import static io.noties.markwon.editor.MarkwonEditorUtils.findDelimited; | import static io.noties.markwon.editor.MarkwonEditorUtils.findDelimited; | ||||||
|  | import static io.noties.markwon.editor.SpannableUtils.append; | ||||||
| import static java.lang.String.format; | import static java.lang.String.format; | ||||||
| import static org.junit.Assert.assertEquals; | import static org.junit.Assert.assertEquals; | ||||||
| import static org.junit.Assert.assertNotNull; | import static org.junit.Assert.assertNotNull; | ||||||
|  | import static org.junit.Assert.assertNull; | ||||||
| 
 | 
 | ||||||
| @RunWith(RobolectricTestRunner.class) | @RunWith(RobolectricTestRunner.class) | ||||||
| @Config(manifest = Config.NONE) | @Config(manifest = Config.NONE) | ||||||
| public class MarkwonEditorUtilsTest { | 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 |     @Test | ||||||
|     public void delimited_single() { |     public void delimited_single() { | ||||||
|         final String input = "**bold**"; |         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.Spanned; | ||||||
| import android.text.TextPaint; | import android.text.TextPaint; | ||||||
| import android.text.method.LinkMovementMethod; | import android.text.method.LinkMovementMethod; | ||||||
| import android.text.style.ClickableSpan; |  | ||||||
| import android.text.style.ForegroundColorSpan; | import android.text.style.ForegroundColorSpan; | ||||||
| import android.text.style.MetricAffectingSpan; | import android.text.style.MetricAffectingSpan; | ||||||
| import android.text.style.StrikethroughSpan; | import android.text.style.StrikethroughSpan; | ||||||
| @ -19,21 +18,23 @@ import android.widget.TextView; | |||||||
| import androidx.annotation.NonNull; | import androidx.annotation.NonNull; | ||||||
| import androidx.annotation.Nullable; | import androidx.annotation.Nullable; | ||||||
| 
 | 
 | ||||||
|  | import org.commonmark.parser.Parser; | ||||||
|  | 
 | ||||||
| import java.util.ArrayList; | import java.util.ArrayList; | ||||||
| import java.util.List; | import java.util.List; | ||||||
| import java.util.concurrent.Executors; | import java.util.concurrent.Executors; | ||||||
| 
 | 
 | ||||||
|  | import io.noties.markwon.AbstractMarkwonPlugin; | ||||||
| import io.noties.markwon.Markwon; | 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.EmphasisSpan; | ||||||
| import io.noties.markwon.core.spans.LinkSpan; |  | ||||||
| import io.noties.markwon.core.spans.StrongEmphasisSpan; | 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.MarkwonEditor; | ||||||
| import io.noties.markwon.editor.MarkwonEditorTextWatcher; | import io.noties.markwon.editor.MarkwonEditorTextWatcher; | ||||||
| import io.noties.markwon.editor.MarkwonEditorUtils; | 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.ext.strikethrough.StrikethroughPlugin; | ||||||
| import io.noties.markwon.linkify.LinkifyPlugin; | import io.noties.markwon.linkify.LinkifyPlugin; | ||||||
| import io.noties.markwon.sample.R; | import io.noties.markwon.sample.R; | ||||||
| @ -92,7 +93,7 @@ public class EditorActivity extends Activity { | |||||||
|         // Use own punctuation span |         // Use own punctuation span | ||||||
| 
 | 
 | ||||||
|         final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) |         final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||||
|                 .withPunctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new) |                 .punctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new) | ||||||
|                 .build(); |                 .build(); | ||||||
| 
 | 
 | ||||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); |         editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||||
| @ -102,32 +103,46 @@ public class EditorActivity extends Activity { | |||||||
|         // An additional span is used to highlight strong-emphasis |         // An additional span is used to highlight strong-emphasis | ||||||
| 
 | 
 | ||||||
|         final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) |         final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||||
|                 // This is required for edit-span cache |                 .useEditHandler(new AbstractEditHandler<StrongEmphasisSpan>() { | ||||||
|                 // 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() { |  | ||||||
|                     @Override |                     @Override | ||||||
|                     public void handle( |                     public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { | ||||||
|                             @NonNull MarkwonEditor.EditSpanStore store, |                         // 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 Editable editable, | ||||||
|                             @NonNull String input, |                             @NonNull String input, | ||||||
|                             @NonNull Object span, |                             @NonNull StrongEmphasisSpan span, | ||||||
|                             int spanStart, |                             int spanStart, | ||||||
|                             int spanTextLength) { |                             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( |                             editable.setSpan( | ||||||
|                                     // `includeEditSpan(Bold.class, Bold::new)` ensured that we have |                                     persistedSpans.get(Bold.class), | ||||||
|                                     //      a span here to use (either reuse existing or create a new one) |                                     match.start(), | ||||||
|                                     store.get(Bold.class), |                                     match.end(), | ||||||
|                                     spanStart, |  | ||||||
|                                     // we know that strong emphasis is delimited with 2 characters on both sides |  | ||||||
|                                     spanStart + spanTextLength + 4, |  | ||||||
|                                     Spanned.SPAN_EXCLUSIVE_EXCLUSIVE |                                     Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ||||||
|                             ); |                             ); | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  | 
 | ||||||
|  |                     @NonNull | ||||||
|  |                     @Override | ||||||
|  |                     public Class<StrongEmphasisSpan> markdownSpanType() { | ||||||
|  |                         return StrongEmphasisSpan.class; | ||||||
|  |                     } | ||||||
|                 }) |                 }) | ||||||
|                 .build(); |                 .build(); | ||||||
| 
 | 
 | ||||||
| @ -156,20 +171,24 @@ public class EditorActivity extends Activity { | |||||||
|         final Markwon markwon = Markwon.builder(this) |         final Markwon markwon = Markwon.builder(this) | ||||||
|                 .usePlugin(StrikethroughPlugin.create()) |                 .usePlugin(StrikethroughPlugin.create()) | ||||||
|                 .usePlugin(LinkifyPlugin.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(); |                 .build(); | ||||||
| 
 | 
 | ||||||
|         final MarkwonTheme theme = markwon.configuration().theme(); |         final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); | ||||||
| 
 |  | ||||||
|         final EditLinkSpan.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); |  | ||||||
| 
 | 
 | ||||||
|         final MarkwonEditor editor = MarkwonEditor.builder(markwon) |         final MarkwonEditor editor = MarkwonEditor.builder(markwon) | ||||||
|                 .includeEditSpan(StrongEmphasisSpan.class, StrongEmphasisSpan::new) |                 .useEditHandler(new EmphasisEditHandler()) | ||||||
|                 .includeEditSpan(EmphasisSpan.class, EmphasisSpan::new) |                 .useEditHandler(new StrongEmphasisEditHandler()) | ||||||
|                 .includeEditSpan(StrikethroughSpan.class, StrikethroughSpan::new) |                 .useEditHandler(new StrikethroughEditHandler()) | ||||||
|                 .includeEditSpan(CodeSpan.class, () -> new CodeSpan(theme)) |                 .useEditHandler(new CodeEditHandler()) | ||||||
|                 .includeEditSpan(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme)) |                 .useEditHandler(new BlockQuoteEditHandler()) | ||||||
|                 .includeEditSpan(EditLinkSpan.class, () -> new EditLinkSpan(onClick)) |                 .useEditHandler(new LinkEditHandler(onClick)) | ||||||
|                 .withEditSpanHandler(createEditSpanHandler()) |  | ||||||
|                 .build(); |                 .build(); | ||||||
| 
 | 
 | ||||||
| //        editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | //        editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||||
| @ -177,100 +196,6 @@ public class EditorActivity extends Activity { | |||||||
|                 editor, Executors.newSingleThreadExecutor(), editText)); |                 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() { |     private void initBottomBar() { | ||||||
|         // all except block-quote wraps if have selection, or inserts at current cursor position |         // 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); |             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; | ||||||
|  |     } | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Dimitry Ivanov
						Dimitry Ivanov