diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 968f2a92..dfb474a6 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -15,7 +15,7 @@ jobs: with: java-version: 1.8 - name: Build with Gradle - run: ./gradlew build + run: ./gradlew build -Prelease deploy: needs: build diff --git a/docs/.vuepress/.artifacts.js b/docs/.vuepress/.artifacts.js index 62c52ab8..50625f79 100644 --- a/docs/.vuepress/.artifacts.js +++ b/docs/.vuepress/.artifacts.js @@ -1,4 +1,4 @@ // this is a generated file, do not modify. To update it run 'collectArtifacts.js' script -const artifacts = [{"id":"core","name":"Core","group":"io.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"ext-latex","name":"LaTeX","group":"io.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"io.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"io.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"io.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"io.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image","name":"Image","group":"io.noties.markwon","description":"Markwon image loading module (with optional GIF and SVG support)"},{"id":"image-glide","name":"Image Glide","group":"io.noties.markwon","description":"Markwon image loading module (based on Glide library)"},{"id":"image-picasso","name":"Image Picasso","group":"io.noties.markwon","description":"Markwon image loading module (based on Picasso library)"},{"id":"linkify","name":"Linkify","group":"io.noties.markwon","description":"Markwon plugin to linkify text (based on Android Linkify)"},{"id":"recycler","name":"Recycler","group":"io.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"recycler-table","name":"Recycler Table","group":"io.noties.markwon","description":"Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget"},{"id":"simple-ext","name":"Simple Extension","group":"io.noties.markwon","description":"Custom extension based on simple delimiter usage"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"io.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}]; +const artifacts = [{"id":"core","name":"Core","group":"io.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"editor","name":"Editor","group":"io.noties.markwon","description":"Markdown editor based on Markwon"},{"id":"ext-latex","name":"LaTeX","group":"io.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"io.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"io.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"io.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"io.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image","name":"Image","group":"io.noties.markwon","description":"Markwon image loading module (with optional GIF and SVG support)"},{"id":"image-glide","name":"Image Glide","group":"io.noties.markwon","description":"Markwon image loading module (based on Glide library)"},{"id":"image-picasso","name":"Image Picasso","group":"io.noties.markwon","description":"Markwon image loading module (based on Picasso library)"},{"id":"linkify","name":"Linkify","group":"io.noties.markwon","description":"Markwon plugin to linkify text (based on Android Linkify)"},{"id":"recycler","name":"Recycler","group":"io.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"recycler-table","name":"Recycler Table","group":"io.noties.markwon","description":"Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget"},{"id":"simple-ext","name":"Simple Extension","group":"io.noties.markwon","description":"Custom extension based on simple delimiter usage"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"io.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}]; export { artifacts }; diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index cd58b64c..f8db7ba5 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -95,6 +95,7 @@ module.exports = { '/docs/v4/core/text-setter.md' ] }, + '/docs/v4/editor/', '/docs/v4/ext-latex/', '/docs/v4/ext-strikethrough/', '/docs/v4/ext-tables/', diff --git a/docs/.vuepress/public/assets/markwon-editor-preview.jpg b/docs/.vuepress/public/assets/markwon-editor-preview.jpg new file mode 100644 index 00000000..e5b29e05 Binary files /dev/null and b/docs/.vuepress/public/assets/markwon-editor-preview.jpg differ diff --git a/docs/.vuepress/public/assets/markwon-editor.mp4 b/docs/.vuepress/public/assets/markwon-editor.mp4 new file mode 100644 index 00000000..8ce65a68 Binary files /dev/null and b/docs/.vuepress/public/assets/markwon-editor.mp4 differ diff --git a/docs/README.md b/docs/README.md index abab2311..300075e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,11 @@ but also gives all the means to tweak the appearance if desired. All markdown fe listed in are supported (including support for **inlined/block HTML code**, **markdown tables**, **images** and **syntax highlight**). +Since version **Markwon** comes with an [editor] to _highlight_ markdown input +as user types for example in **EditText**. + +[editor]: /docs/v4/editor/ + ## Supported markdown features * Emphasis (`*`, `_`) diff --git a/docs/docs/v4/editor/README.md b/docs/docs/v4/editor/README.md new file mode 100644 index 00000000..9af9bf6c --- /dev/null +++ b/docs/docs/v4/editor/README.md @@ -0,0 +1,25 @@ +# Editor + + + +Markdown editing highlight for Android based on **Markwon**. + + + + + +## Getting started with editor + +:::warning Implementation Detail +It must be mentioned that highlight is implemented via text diff. Everything +that is present in raw markdown input and missing from rendered result is considered +to be _punctuation_. +::: \ No newline at end of file diff --git a/markwon-editor/README.md b/markwon-editor/README.md index 896a8723..f191ee4f 100644 --- a/markwon-editor/README.md +++ b/markwon-editor/README.md @@ -3,4 +3,11 @@ Markdown editor for Android based on `Markwon`. Main principle: _difference_ between input text and rendered markdown is considered to be -_punctuation_. \ No newline at end of file +_punctuation_. + + +## Limitations + +Tables and LaTeX nodes won't be rendered correctly. They will be treated as _punctuation_ +as whole. This comes from their implementation - they are _mocked_ and do not present +in final result as text and thus cannot be _diffed_. \ No newline at end of file diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/EditSpanHandlerBuilder.java b/markwon-editor/src/main/java/io/noties/markwon/editor/EditSpanHandlerBuilder.java index 09c1706e..96c47424 100644 --- a/markwon-editor/src/main/java/io/noties/markwon/editor/EditSpanHandlerBuilder.java +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/EditSpanHandlerBuilder.java @@ -31,7 +31,7 @@ public class EditSpanHandlerBuilder { private final Map, EditSpanHandlerTyped> map = new HashMap<>(3); @NonNull - public EditSpanHandlerBuilder include( + public EditSpanHandlerBuilder handleMarkdownSpan( @NonNull Class type, @NonNull EditSpanHandlerTyped handler) { map.put(type, handler); diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java index 2427b3e0..e6e0b185 100644 --- a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java +++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java @@ -10,11 +10,6 @@ import java.util.Map; import io.noties.markwon.Markwon; -// todo: how to reuse existing spanFactories? to obtain a value they require render-props.... -// maybe.. mock them? plus, spanFactory can return multiple spans - -// todo: as we do text-diff, then images, latex and tables won't be handled... they will be treated as punctuation as-whole.. - /** * @see #builder(Markwon) * @see #create(Markwon) @@ -24,6 +19,9 @@ import io.noties.markwon.Markwon; */ public abstract class MarkwonEditor { + /** + * Represents cache of spans that are used during highlight + */ public interface EditSpanStore { /** diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java new file mode 100644 index 00000000..9bbfc829 --- /dev/null +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java @@ -0,0 +1,166 @@ +package io.noties.markwon.editor; + +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import androidx.annotation.NonNull; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.noties.markwon.Markwon; +import io.noties.markwon.editor.MarkwonEditorImpl.EditSpanStoreImpl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class MarkwonEditorImplTest { + + @Test + public void extract_spans() { + + final class One { + } + final class Two { + } + final class Three { + } + + final SpannableStringBuilder builder = new SpannableStringBuilder(); + append(builder, "one", new One()); + append(builder, "two", new Two(), new Two()); + append(builder, "three", new Three(), new Three(), new Three()); + + final Map, List> map = MarkwonEditorImpl.extractSpans( + builder, + Arrays.asList(One.class, Three.class)); + + assertEquals(2, map.size()); + + assertNotNull(map.get(One.class)); + assertNull(map.get(Two.class)); + assertNotNull(map.get(Three.class)); + + //noinspection ConstantConditions + assertEquals(1, map.get(One.class).size()); + //noinspection ConstantConditions + assertEquals(3, map.get(Three.class).size()); + } + + private static void append(@NonNull SpannableStringBuilder builder, @NonNull String text, Object... spans) { + final int start = builder.length(); + builder.append(text); + final int end = builder.length(); + for (Object span : spans) { + builder.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + @Test + public void edit_span_store_span_not_included() { + // When store is requesting a span that is not included -> exception is raised + + final Map, MarkwonEditor.EditSpanFactory> map = Collections.emptyMap(); + + final EditSpanStoreImpl impl = new EditSpanStoreImpl(new SpannableStringBuilder(), map); + + try { + impl.get(Object.class); + fail(); + } catch (IllegalStateException e) { + assertTrue(e.getMessage(), e.getMessage().contains("not registered, use Builder#includeEditSpan method to register")); + } + } + + @Test + public void edit_span_store_reuse() { + // when a span is present in supplied spannable -> it will be used + + final class One { + } + final SpannableStringBuilder builder = new SpannableStringBuilder(); + final One one = new One(); + append(builder, "One", one); + + final Map, MarkwonEditor.EditSpanFactory> map = new HashMap, MarkwonEditor.EditSpanFactory>() {{ + // null in case it _will_ be used -> thus NPE + put(One.class, null); + }}; + + final EditSpanStoreImpl impl = new EditSpanStoreImpl(builder, map); + + assertEquals(one, impl.get(One.class)); + } + + @Test + public void edit_span_store_factory_create() { + // when span is not present in spannable -> new one will be created via factory + + final class Two { + } + + final SpannableStringBuilder builder = new SpannableStringBuilder(); + final Two two = new Two(); + append(builder, "two", two); + + final MarkwonEditor.EditSpanFactory factory = mock(MarkwonEditor.EditSpanFactory.class); + + final Map, MarkwonEditor.EditSpanFactory> map = new HashMap, MarkwonEditor.EditSpanFactory>() {{ + put(Two.class, factory); + }}; + + final EditSpanStoreImpl impl = new EditSpanStoreImpl(builder, map); + + // first one will be the same as we had created before, + // second one will be created via factory + + assertEquals(two, impl.get(Two.class)); + + verify(factory, never()).create(); + + impl.get(Two.class); + verify(factory, times(1)).create(); + } + + @Test + public void process() { + // create markwon + final Markwon markwon = Markwon.create(RuntimeEnvironment.application); + + // default punctuation + final MarkwonEditor editor = MarkwonEditor.create(markwon); + + final SpannableStringBuilder builder = new SpannableStringBuilder("**bold**"); + + editor.process(builder); + + final PunctuationSpan[] spans = builder.getSpans(0, builder.length(), PunctuationSpan.class); + assertEquals(2, spans.length); + + final PunctuationSpan first = spans[0]; + assertEquals(0, builder.getSpanStart(first)); + assertEquals(2, builder.getSpanEnd(first)); + + final PunctuationSpan second = spans[1]; + assertEquals(6, builder.getSpanStart(second)); + assertEquals(8, builder.getSpanEnd(second)); + } +} diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java new file mode 100644 index 00000000..b177a143 --- /dev/null +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java @@ -0,0 +1,45 @@ +package io.noties.markwon.editor; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import io.noties.markwon.Markwon; +import io.noties.markwon.editor.MarkwonEditor.Builder; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class MarkwonEditorTest { + + @Test + public void builder_no_config() { + // must create a default instance without exceptions + + try { + new Builder(mock(Markwon.class)).build(); + assertTrue(true); + } catch (Throwable t) { + fail(t.getMessage()); + } + } + + @Test + public void builder_with_edit_spans_but_no_handler() { + // if edit spans are specified, but no edit span handler is present -> exception is thrown + + try { + //noinspection unchecked + new Builder(mock(Markwon.class)) + .includeEditSpan(Object.class, mock(MarkwonEditor.EditSpanFactory.class)) + .build(); + fail(); + } catch (IllegalStateException e) { + assertTrue(e.getMessage(), e.getMessage().contains("There is no need to include edit spans ")); + } + } +} \ No newline at end of file diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java new file mode 100644 index 00000000..9d144cdc --- /dev/null +++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java @@ -0,0 +1,94 @@ +package io.noties.markwon.editor; + +import android.text.Editable; +import android.widget.EditText; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.concurrent.ExecutorService; + +import io.noties.markwon.editor.MarkwonEditor.PreRenderResult; +import io.noties.markwon.editor.MarkwonEditor.PreRenderResultListener; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class MarkwonEditorTextWatcherTest { + + @Test + public void w_process() { + + final MarkwonEditor editor = mock(MarkwonEditor.class); + final Editable editable = mock(Editable.class); + + final MarkwonEditorTextWatcher watcher = MarkwonEditorTextWatcher.withProcess(editor); + + watcher.afterTextChanged(editable); + + verify(editor, times(1)).process(eq(editable)); + } + + @Test + public void w_pre_render() { + + final MarkwonEditor editor = mock(MarkwonEditor.class); + final Editable editable = mock(Editable.class); + final ExecutorService service = mock(ExecutorService.class); + final EditText editText = mock(EditText.class); + + when(editText.getText()).thenReturn(editable); + + when(service.submit(any(Runnable.class))).thenAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + ((Runnable) invocation.getArgument(0)).run(); + return null; + } + }); + + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + ((Runnable) invocation.getArgument(0)).run(); + return null; + } + }).when(editText).post(any(Runnable.class)); + + final MarkwonEditorTextWatcher watcher = MarkwonEditorTextWatcher.withPreRender( + editor, + service, + editText); + + watcher.afterTextChanged(editable); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(PreRenderResultListener.class); + + verify(service, times(1)).submit(any(Runnable.class)); + verify(editor, times(1)).preRender(eq(editable), captor.capture()); + + final PreRenderResultListener listener = captor.getValue(); + final PreRenderResult result = mock(PreRenderResult.class); + + // for simplicity return the same editable instance (same hashCode) + when(result.resultEditable()).thenReturn(editable); + + listener.onPreRenderResult(result); + + verify(result, times(1)).resultEditable(); + verify(result, times(1)).dispatchTo(eq(editable)); + } +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 71d2277a..6bc6ef02 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -28,7 +28,10 @@ - + + diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java index a2d2fc9b..271b6b69 100644 --- a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java @@ -3,17 +3,23 @@ package io.noties.markwon.sample.editor; import android.app.Activity; import android.os.Bundle; import android.text.Editable; +import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextPaint; import android.text.style.CharacterStyle; import android.text.style.ForegroundColorSpan; import android.text.style.MetricAffectingSpan; import android.text.style.StrikethroughSpan; +import android.view.View; +import android.widget.Button; import android.widget.EditText; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.Executors; import io.noties.markwon.Markwon; @@ -40,7 +46,7 @@ public class EditorActivity extends Activity { setContentView(R.layout.activity_editor); this.editText = findViewById(R.id.edit_text); - + initBottomBar(); // simple_process(); @@ -164,7 +170,7 @@ public class EditorActivity extends Activity { private static MarkwonEditor.EditSpanHandler createEditSpanHandler() { // Please note that here we specify spans THAT ARE USED IN MARKDOWN return EditSpanHandlerBuilder.create() - .include(StrongEmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + .handleMarkdownSpan(StrongEmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { editable.setSpan( store.get(StrongEmphasisSpan.class), spanStart, @@ -172,7 +178,7 @@ public class EditorActivity extends Activity { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); }) - .include(EmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + .handleMarkdownSpan(EmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { editable.setSpan( store.get(EmphasisSpan.class), spanStart, @@ -180,7 +186,7 @@ public class EditorActivity extends Activity { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); }) - .include(StrikethroughSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + .handleMarkdownSpan(StrikethroughSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { editable.setSpan( store.get(StrikethroughSpan.class), spanStart, @@ -188,7 +194,7 @@ public class EditorActivity extends Activity { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); }) - .include(CodeSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + .handleMarkdownSpan(CodeSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { // we do not add offset here because markwon (by default) adds spaces // around inline code editable.setSpan( @@ -198,7 +204,7 @@ public class EditorActivity extends Activity { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); }) - .include(CodeBlockSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + .handleMarkdownSpan(CodeBlockSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { // we do not handle indented code blocks here if (input.charAt(spanStart) == '`') { final int firstLineEnd = input.indexOf('\n', spanStart); @@ -214,7 +220,7 @@ public class EditorActivity extends Activity { ); } }) - .include(BlockQuoteSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + .handleMarkdownSpan(BlockQuoteSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { editable.setSpan( store.get(BlockQuoteSpan.class), spanStart, @@ -222,7 +228,7 @@ public class EditorActivity extends Activity { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ); }) - .include(LinkSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { + .handleMarkdownSpan(LinkSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { editable.setSpan( store.get(EditLinkSpan.class), // add underline only for link text @@ -235,6 +241,81 @@ public class EditorActivity extends Activity { .build(); } + private void initBottomBar() { + // all except block-quote wraps if have selection, or inserts at current cursor position + + final Button bold = findViewById(R.id.bold); + final Button italic = findViewById(R.id.italic); + final Button strike = findViewById(R.id.strike); + final Button quote = findViewById(R.id.quote); + final Button code = findViewById(R.id.code); + + addSpan(bold, new StrongEmphasisSpan()); + addSpan(italic, new EmphasisSpan()); + addSpan(strike, new StrikethroughSpan()); + + bold.setOnClickListener(new InsertOrWrapClickListener(editText, "**")); + italic.setOnClickListener(new InsertOrWrapClickListener(editText, "_")); + strike.setOnClickListener(new InsertOrWrapClickListener(editText, "~~")); + code.setOnClickListener(new InsertOrWrapClickListener(editText, "`")); + + quote.setOnClickListener(v -> { + final int start = editText.getSelectionStart(); + final int end = editText.getSelectionEnd(); + if (start == end) { + editText.getText().insert(start, "> "); + } else { + // wrap the whole selected area in a quote + final List newLines = new ArrayList<>(3); + newLines.add(start); + + final String text = editText.getText().subSequence(start, end).toString(); + int index = text.indexOf('\n'); + while (index != -1) { + newLines.add(start + index); + index = text.indexOf('\n', index + 1); + } + + for (int i = newLines.size() - 1; i >= 0; i--) { + editText.getText().insert(newLines.get(i), "> "); + } + } + }); + } + + private static void addSpan(@NonNull TextView textView, Object... spans) { + final SpannableStringBuilder builder = new SpannableStringBuilder(textView.getText()); + final int end = builder.length(); + for (Object span : spans) { + builder.setSpan(span, 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + textView.setText(builder); + } + + private static class InsertOrWrapClickListener implements View.OnClickListener { + + private final EditText editText; + private final String text; + + InsertOrWrapClickListener(@NonNull EditText editText, @NonNull String text) { + this.editText = editText; + this.text = text; + } + + @Override + public void onClick(View v) { + final int start = editText.getSelectionStart(); + final int end = editText.getSelectionEnd(); + if (start == end) { + // insert at current position + editText.getText().insert(start, text); + } else { + editText.getText().insert(end, text); + editText.getText().insert(start, text); + } + } + } + private static class CustomPunctuationSpan extends ForegroundColorSpan { CustomPunctuationSpan() { super(0xFFFF0000); // RED diff --git a/sample/src/main/res/layout/activity_editor.xml b/sample/src/main/res/layout/activity_editor.xml index e5428e0f..c401a8cb 100644 --- a/sample/src/main/res/layout/activity_editor.xml +++ b/sample/src/main/res/layout/activity_editor.xml @@ -1,17 +1,72 @@ - - + + + + + + + android:orientation="horizontal"> - \ No newline at end of file +