Added editor tests

This commit is contained in:
Dimitry Ivanov 2019-11-07 16:40:55 +03:00
parent f1e750b305
commit bd53c014a1
16 changed files with 506 additions and 26 deletions

View File

@ -15,7 +15,7 @@ jobs:
with: with:
java-version: 1.8 java-version: 1.8
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew build run: ./gradlew build -Prelease
deploy: deploy:
needs: build needs: build

View File

@ -1,4 +1,4 @@
// this is a generated file, do not modify. To update it run 'collectArtifacts.js' script // 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 }; export { artifacts };

View File

@ -95,6 +95,7 @@ module.exports = {
'/docs/v4/core/text-setter.md' '/docs/v4/core/text-setter.md'
] ]
}, },
'/docs/v4/editor/',
'/docs/v4/ext-latex/', '/docs/v4/ext-latex/',
'/docs/v4/ext-strikethrough/', '/docs/v4/ext-strikethrough/',
'/docs/v4/ext-tables/', '/docs/v4/ext-tables/',

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

View File

@ -21,6 +21,11 @@ but also gives all the means to tweak the appearance if desired. All markdown fe
listed in <Link name="commonmark-spec" /> are supported (including support for **inlined/block HTML code**, listed in <Link name="commonmark-spec" /> are supported (including support for **inlined/block HTML code**,
**markdown tables**, **images** and **syntax highlight**). **markdown tables**, **images** and **syntax highlight**).
Since version <Badge text="4.2.0" /> **Markwon** comes with an [editor] to _highlight_ markdown input
as user types for example in **EditText**.
[editor]: /docs/v4/editor/
## Supported markdown features ## Supported markdown features
* Emphasis (`*`, `_`) * Emphasis (`*`, `_`)

View File

@ -0,0 +1,25 @@
# Editor <Badge text="4.2.0" />
<MavenBadge4 :artifact="'editor'" />
Markdown editing highlight for Android based on **Markwon**.
<style>
video {
max-height: 82vh;
}
</style>
<video controls="true" loop="" :poster="$withBase('/assets/markwon-editor-preview.jpg')">
<source :src="$withBase('/assets/markwon-editor.mp4')" type="video/mp4">
You browser does not support mp4 playback, try downloading video file
<a :href="$withBase('/assets/markwon-editor.mp4')">directly</a>
</video>
## 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_.
:::

View File

@ -4,3 +4,10 @@ Markdown editor for Android based on `Markwon`.
Main principle: _difference_ between input text and rendered markdown is considered to be Main principle: _difference_ between input text and rendered markdown is considered to be
_punctuation_. _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_.

View File

@ -31,7 +31,7 @@ public class EditSpanHandlerBuilder {
private final Map<Class<?>, EditSpanHandlerTyped> map = new HashMap<>(3); private final Map<Class<?>, EditSpanHandlerTyped> map = new HashMap<>(3);
@NonNull @NonNull
public <T> EditSpanHandlerBuilder include( public <T> EditSpanHandlerBuilder handleMarkdownSpan(
@NonNull Class<T> type, @NonNull Class<T> type,
@NonNull EditSpanHandlerTyped<T> handler) { @NonNull EditSpanHandlerTyped<T> handler) {
map.put(type, handler); map.put(type, handler);

View File

@ -10,11 +10,6 @@ import java.util.Map;
import io.noties.markwon.Markwon; 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 #builder(Markwon)
* @see #create(Markwon) * @see #create(Markwon)
@ -24,6 +19,9 @@ import io.noties.markwon.Markwon;
*/ */
public abstract class MarkwonEditor { public abstract class MarkwonEditor {
/**
* Represents cache of spans that are used during highlight
*/
public interface EditSpanStore { public interface EditSpanStore {
/** /**

View File

@ -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<Class<?>, List<Object>> map = MarkwonEditorImpl.extractSpans(
builder,
Arrays.asList(One.class, Three.class));
assertEquals(2, map.size());
assertNotNull(map.get(One.class));
assertNull(map.get(Two.class));
assertNotNull(map.get(Three.class));
//noinspection ConstantConditions
assertEquals(1, map.get(One.class).size());
//noinspection ConstantConditions
assertEquals(3, map.get(Three.class).size());
}
private static void append(@NonNull SpannableStringBuilder builder, @NonNull String text, Object... spans) {
final int start = builder.length();
builder.append(text);
final int end = builder.length();
for (Object span : spans) {
builder.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
@Test
public void edit_span_store_span_not_included() {
// When store is requesting a span that is not included -> exception is raised
final Map<Class<?>, MarkwonEditor.EditSpanFactory> map = Collections.emptyMap();
final EditSpanStoreImpl impl = new EditSpanStoreImpl(new SpannableStringBuilder(), map);
try {
impl.get(Object.class);
fail();
} catch (IllegalStateException e) {
assertTrue(e.getMessage(), e.getMessage().contains("not registered, use Builder#includeEditSpan method to register"));
}
}
@Test
public void edit_span_store_reuse() {
// when a span is present in supplied spannable -> it will be used
final class One {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
final One one = new One();
append(builder, "One", one);
final Map<Class<?>, MarkwonEditor.EditSpanFactory> map = new HashMap<Class<?>, MarkwonEditor.EditSpanFactory>() {{
// null in case it _will_ be used -> thus NPE
put(One.class, null);
}};
final EditSpanStoreImpl impl = new EditSpanStoreImpl(builder, map);
assertEquals(one, impl.get(One.class));
}
@Test
public void edit_span_store_factory_create() {
// when span is not present in spannable -> new one will be created via factory
final class Two {
}
final SpannableStringBuilder builder = new SpannableStringBuilder();
final Two two = new Two();
append(builder, "two", two);
final MarkwonEditor.EditSpanFactory factory = mock(MarkwonEditor.EditSpanFactory.class);
final Map<Class<?>, MarkwonEditor.EditSpanFactory> map = new HashMap<Class<?>, MarkwonEditor.EditSpanFactory>() {{
put(Two.class, factory);
}};
final EditSpanStoreImpl impl = new EditSpanStoreImpl(builder, map);
// first one will be the same as we had created before,
// second one will be created via factory
assertEquals(two, impl.get(Two.class));
verify(factory, never()).create();
impl.get(Two.class);
verify(factory, times(1)).create();
}
@Test
public void process() {
// create markwon
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));
}
}

View File

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

View File

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

View File

@ -28,7 +28,10 @@
<activity android:name=".simpleext.SimpleExtActivity" /> <activity android:name=".simpleext.SimpleExtActivity" />
<activity android:name=".customextension2.CustomExtensionActivity2" /> <activity android:name=".customextension2.CustomExtensionActivity2" />
<activity android:name=".precomputed.PrecomputedActivity" /> <activity android:name=".precomputed.PrecomputedActivity" />
<activity android:name=".editor.EditorActivity" />
<activity
android:name=".editor.EditorActivity"
android:windowSoftInputMode="adjustResize" />
</application> </application>

View File

@ -3,17 +3,23 @@ package io.noties.markwon.sample.editor;
import android.app.Activity; import android.app.Activity;
import android.os.Bundle; import android.os.Bundle;
import android.text.Editable; import android.text.Editable;
import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextPaint; import android.text.TextPaint;
import android.text.style.CharacterStyle; import android.text.style.CharacterStyle;
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;
import android.view.View;
import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import io.noties.markwon.Markwon; import io.noties.markwon.Markwon;
@ -40,7 +46,7 @@ public class EditorActivity extends Activity {
setContentView(R.layout.activity_editor); setContentView(R.layout.activity_editor);
this.editText = findViewById(R.id.edit_text); this.editText = findViewById(R.id.edit_text);
initBottomBar();
// simple_process(); // simple_process();
@ -164,7 +170,7 @@ public class EditorActivity extends Activity {
private static MarkwonEditor.EditSpanHandler createEditSpanHandler() { private static MarkwonEditor.EditSpanHandler createEditSpanHandler() {
// Please note that here we specify spans THAT ARE USED IN MARKDOWN // Please note that here we specify spans THAT ARE USED IN MARKDOWN
return EditSpanHandlerBuilder.create() return EditSpanHandlerBuilder.create()
.include(StrongEmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { .handleMarkdownSpan(StrongEmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
editable.setSpan( editable.setSpan(
store.get(StrongEmphasisSpan.class), store.get(StrongEmphasisSpan.class),
spanStart, spanStart,
@ -172,7 +178,7 @@ public class EditorActivity extends Activity {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
); );
}) })
.include(EmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { .handleMarkdownSpan(EmphasisSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
editable.setSpan( editable.setSpan(
store.get(EmphasisSpan.class), store.get(EmphasisSpan.class),
spanStart, spanStart,
@ -180,7 +186,7 @@ public class EditorActivity extends Activity {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
); );
}) })
.include(StrikethroughSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { .handleMarkdownSpan(StrikethroughSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
editable.setSpan( editable.setSpan(
store.get(StrikethroughSpan.class), store.get(StrikethroughSpan.class),
spanStart, spanStart,
@ -188,7 +194,7 @@ public class EditorActivity extends Activity {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE 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 // we do not add offset here because markwon (by default) adds spaces
// around inline code // around inline code
editable.setSpan( editable.setSpan(
@ -198,7 +204,7 @@ public class EditorActivity extends Activity {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE 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 // we do not handle indented code blocks here
if (input.charAt(spanStart) == '`') { if (input.charAt(spanStart) == '`') {
final int firstLineEnd = input.indexOf('\n', 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( editable.setSpan(
store.get(BlockQuoteSpan.class), store.get(BlockQuoteSpan.class),
spanStart, spanStart,
@ -222,7 +228,7 @@ public class EditorActivity extends Activity {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
); );
}) })
.include(LinkSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> { .handleMarkdownSpan(LinkSpan.class, (store, editable, input, span, spanStart, spanTextLength) -> {
editable.setSpan( editable.setSpan(
store.get(EditLinkSpan.class), store.get(EditLinkSpan.class),
// add underline only for link text // add underline only for link text
@ -235,6 +241,81 @@ public class EditorActivity extends Activity {
.build(); .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<Integer> 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 { private static class CustomPunctuationSpan extends ForegroundColorSpan {
CustomPunctuationSpan() { CustomPunctuationSpan() {
super(0xFFFF0000); // RED super(0xFFFF0000); // RED

View File

@ -1,17 +1,72 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" android:clipToPadding="false"
android:orientation="vertical"
android:padding="8dip"> android:padding="8dip">
<EditText <FrameLayout
android:id="@+id/edit_text" android:layout_width="match_parent"
android:layout_height="0px"
android:layout_weight="1">
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="none"
android:hint="Markdown..."
android:inputType="text|textLongMessage|textMultiLine"
android:maxLines="100" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:autofillHints="none" android:orientation="horizontal">
android:hint="Markdown..."
android:inputType="text|textLongMessage|textMultiLine"
android:maxLines="100" />
</FrameLayout> <Button
android:id="@+id/bold"
android:layout_width="0px"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="B"
android:typeface="monospace" />
<Button
android:id="@+id/italic"
android:layout_width="0px"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="I"
android:typeface="monospace" />
<Button
android:id="@+id/strike"
android:layout_width="0px"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="S"
android:typeface="monospace" />
<Button
android:id="@+id/quote"
android:layout_width="0px"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text=">"
android:typeface="monospace" />
<Button
android:id="@+id/code"
android:layout_width="0px"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="`"
android:typeface="monospace" />
</LinearLayout>
</LinearLayout>