Use markdown module from nextcloud-commons lib

Signed-off-by: Stefan Niedermann <info@niedermann.it>
This commit is contained in:
Stefan Niedermann 2021-10-18 13:12:20 +02:00
parent fae4740719
commit 8f242754f6
59 changed files with 5 additions and 4688 deletions

View file

@ -75,15 +75,15 @@ android {
}
dependencies {
// Markdown
implementation project(path: ':markdown')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
// Nextcloud SSO
implementation 'com.github.nextcloud:Android-SingleSignOn:0.5.6'
implementation 'com.github.stefan-niedermann.nextcloud-commons:sso-glide:1.4.0'
implementation 'com.github.stefan-niedermann.nextcloud-commons:exception:1.4.0'
implementation 'com.github.stefan-niedermann.nextcloud-commons:sso-glide:1.5.0'
implementation 'com.github.stefan-niedermann.nextcloud-commons:exception:1.5.0'
implementation('com.github.stefan-niedermann.nextcloud-commons:markdown:1.5.0') {
exclude group: 'org.jetbrains', module: 'annotations-java5'
}
implementation 'com.github.stefan-niedermann:android-commons:0.2.0'
// Glide

1
markdown/.gitignore vendored
View file

@ -1 +0,0 @@
/build

View file

@ -1,67 +0,0 @@
plugins {
id 'com.android.library'
}
android {
compileSdkVersion 31
buildToolsVersion "31.0.0"
defaultConfig {
minSdkVersion 22
targetSdkVersion 31
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
testOptions {
unitTests {
includeAndroidResources true
}
}
}
dependencies {
implementation 'com.github.nextcloud:Android-SingleSignOn:0.5.6'
implementation 'com.github.stefan-niedermann:android-commons:0.2.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.lifecycle:lifecycle-livedata:2.3.1'
implementation 'org.jsoup:jsoup:1.14.3'
def markwonVersion = '4.6.2'
implementation "io.noties.markwon:core:$markwonVersion"
implementation "io.noties.markwon:editor:$markwonVersion"
implementation "io.noties.markwon:ext-strikethrough:$markwonVersion"
implementation "io.noties.markwon:ext-tables:$markwonVersion"
implementation "io.noties.markwon:ext-tasklist:$markwonVersion"
implementation "io.noties.markwon:html:$markwonVersion"
implementation "io.noties.markwon:image:$markwonVersion"
implementation "io.noties.markwon:image-glide:$markwonVersion"
implementation "io.noties.markwon:linkify:$markwonVersion"
implementation "io.noties.markwon:simple-ext:$markwonVersion"
implementation "io.noties.markwon:inline-parser:$markwonVersion"
implementation("io.noties.markwon:syntax-highlight:$markwonVersion") {
exclude group: 'org.jetbrains', module: 'annotations-java5'
}
implementation('io.noties:prism4j:2.0.0') {
exclude group: 'org.jetbrains', module: 'annotations-java5'
}
annotationProcessor 'io.noties:prism4j-bundler:2.0.0'
implementation 'org.jetbrains:annotations:22.0.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
testImplementation 'androidx.test:core:1.4.0'
testImplementation 'androidx.arch.core:core-testing:2.1.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:4.0.0'
testImplementation 'org.robolectric:robolectric:4.6.1'
}

View file

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="it.niedermann.android.markdown">
</manifest>

View file

@ -1,80 +0,0 @@
package it.niedermann.android.markdown;
import android.text.Editable;
import android.text.Html;
import android.text.style.BulletSpan;
import androidx.annotation.NonNull;
import androidx.core.text.HtmlCompat;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.xml.sax.XMLReader;
import java.util.Stack;
/**
* Adds <code></code> to unordered list items and a counter to ordered list items.
* Call {@link #prepareTagHandling(String)}, so the default handler of {@link Html#fromHtml(String, int)} does not prevent the handling.
*/
public class ListTagHandler implements Html.TagHandler {
private static final String X_OL = "x-ol";
private static final String X_UL = "x-ul";
private static final String X_LI = "x-li";
private final Stack<String> parents = new Stack<>();
private final Stack<Integer> listItemIndex = new Stack<>();
@Override
public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
if (X_OL.equals(tag)) {
if (opening) {
parents.push(X_OL);
listItemIndex.push(1);
} else {
parents.pop();
listItemIndex.pop();
}
} else if (X_UL.equals(tag)) {
if (opening) {
parents.push(X_UL);
} else {
parents.pop();
}
} else if (X_LI.equals(tag)) {
if (X_OL.equals(parents.peek())) {
if (opening) {
output.append("\n");
for (int nestingLevel = 1; nestingLevel < parents.size(); nestingLevel++) {
output.append("\t\t");
}
output.append(String.valueOf(listItemIndex.peek())).append(". ");
listItemIndex.push(listItemIndex.pop() + 1);
}
} else if (X_UL.equals(parents.peek())) {
if (opening) {
output.append("\n");
for (int nestingLevel = 1; nestingLevel < parents.size(); nestingLevel++) {
output.append("\t\t");
}
output.append("");
}
}
}
}
/**
* Replace the list tags with custom tags to prevent them being handeled by {@link HtmlCompat}.
* Otherwise, all <code>li</code> tags will be replaced with {@link BulletSpan} which is not the
* desired behavior of ordered list items.
*/
@NonNull
public static String prepareTagHandling(@NonNull String html) {
final Document document = Jsoup.parse(html);
document.getElementsByTag("ol").tagName(X_OL);
document.getElementsByTag("ul").tagName(X_UL);
document.getElementsByTag("li").tagName(X_LI);
return document.outerHtml();
}
}

View file

@ -1,88 +0,0 @@
package it.niedermann.android.markdown;
import android.text.style.URLSpan;
import android.util.Log;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Can be used for editors and viewers as well.
* Viewer can support basic edit features, like toggling checkboxes
*/
public interface MarkdownEditor {
String TAG = MarkdownEditor.class.getSimpleName();
/**
* The given {@link String} will be parsed and rendered
*/
void setMarkdownString(CharSequence text);
/**
* The given {@link String} will be parsed and rendered and the {@param afterRender} will be called after the rendering finished
*/
void setMarkdownString(CharSequence text, @Nullable Runnable afterRender);
/**
* Will replace all `@mention`s of Nextcloud users with the avatar and given display name.
*
* @param mentions {@link Map} of mentions, where the key is the user id and the value is the display name
*/
default void setMarkdownStringAndHighlightMentions(CharSequence text, @NonNull Map<String, String> mentions) {
setMarkdownString(text);
}
/**
* @return the source {@link CharSequence} of the currently rendered markdown
*/
LiveData<CharSequence> getMarkdownString();
/**
* Similar to {@link #getMarkdownString()} but without {@link LiveData}. Will remove previously set {@link Consumer}s.
*
* @param listener a {@link Consumer} which will receive the changed markdown string.
*/
void setMarkdownStringChangedListener(@Nullable Consumer<CharSequence> listener);
void setEnabled(boolean enabled);
/**
* @param color which will be used for highlighting. See {@link #setSearchText(CharSequence)}
*/
default void setSearchColor(@ColorInt int color) {
Log.w(TAG, "This feature is not supported by the currently used implementation.");
}
/**
* See {@link #setSearchText(CharSequence, Integer)}
*/
default void setSearchText(@Nullable CharSequence searchText) {
setSearchText(searchText, null);
}
/**
* Highlights the given {@param searchText} in the {@link MarkdownEditor}.
*
* @param searchText the term to highlight
* @param current highlights the occurrence of the {@param searchText} at this position special
*/
default void setSearchText(@Nullable CharSequence searchText, @Nullable Integer current) {
Log.w(TAG, "This feature is not supported by the currently used implementation.");
}
/**
* Intercepts each click on a clickable element like {@link URLSpan}s
*
* @param callback Will be called on a click. When the {@param callback} returns <code>true</code>, the click will not be propagated further.
*/
default void registerOnLinkClickCallback(@NonNull Function<String, Boolean> callback) {
Log.w(TAG, "This feature is not supported by the currently used implementation.");
}
}

View file

@ -1,23 +0,0 @@
package it.niedermann.android.markdown;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
public class MarkdownEditorImpl extends MarkwonMarkdownEditor {
public MarkdownEditorImpl(@NonNull Context context) {
super(context);
}
public MarkdownEditorImpl(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MarkdownEditorImpl(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
}

View file

@ -1,529 +0,0 @@
package it.niedermann.android.markdown;
import android.content.Context;
import android.graphics.Paint;
import android.os.Build;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.QuoteSpan;
import android.util.Log;
import android.util.Pair;
import android.widget.RemoteViews.RemoteView;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import java.util.Collections;
import java.util.LinkedList;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.noties.markwon.Markwon;
import it.niedermann.android.markdown.model.EListType;
import it.niedermann.android.markdown.model.SearchSpan;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public class MarkdownUtil {
private static final String TAG = MarkdownUtil.class.getSimpleName();
private static final Parser PARSER = Parser.builder().build();
private static final HtmlRenderer RENDERER = HtmlRenderer.builder().softbreak("<br>").build();
private static final Pattern PATTERN_CODE_FENCE = Pattern.compile("^(`{3,})");
private static final Pattern PATTERN_ORDERED_LIST_ITEM = Pattern.compile("^(\\d+).\\s.+$");
private static final Pattern PATTERN_ORDERED_LIST_ITEM_EMPTY = Pattern.compile("^(\\d+).\\s$");
private static final Pattern PATTERN_MARKDOWN_LINK = Pattern.compile("\\[(.+)?]\\(([^ ]+?)?( \"(.+)\")?\\)");
private static final String PATTERN_QUOTE_BOLD_PUNCTUATION = Pattern.quote("**");
private static final Optional<String> CHECKBOX_CHECKED_EMOJI = getCheckboxEmoji(true);
private static final Optional<String> CHECKBOX_UNCHECKED_EMOJI = getCheckboxEmoji(false);
private MarkdownUtil() {
// Util class
}
/**
* {@link RemoteView}s have a limited subset of supported classes to maintain compatibility with many different launchers.
* <p>
* Since {@link Markwon} makes heavy use of custom spans, this won't look nice e. g. at app widgets, because they simply won't be rendered.
* Therefore we currently use {@link HtmlCompat} to filter supported spans from the output of {@link HtmlRenderer} as an intermediate step.
*/
public static CharSequence renderForRemoteView(@NonNull Context context, @NonNull String content) {
// Create HTML string from Markup
final String html = RENDERER.render(PARSER.parse(replaceCheckboxesWithEmojis(content)));
// Create Spanned from HTML, with special handling for ordered list items
final Spanned spanned = HtmlCompat.fromHtml(ListTagHandler.prepareTagHandling(html), 0, null, new ListTagHandler());
// Enhance colors and margins of the Spanned
return customizeQuoteSpanAppearance(context, spanned, 5, 30);
}
@SuppressWarnings("SameParameterValue")
private static Spanned customizeQuoteSpanAppearance(@NonNull Context context, @NonNull Spanned input, int stripeWidth, int gapWidth) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return input;
}
final var ssb = new SpannableStringBuilder(input);
final var originalQuoteSpans = ssb.getSpans(0, ssb.length(), QuoteSpan.class);
@ColorInt final int colorBlockQuote = ContextCompat.getColor(context, R.color.block_quote);
for (final var originalQuoteSpan : originalQuoteSpans) {
final int start = ssb.getSpanStart(originalQuoteSpan);
final int end = ssb.getSpanEnd(originalQuoteSpan);
ssb.removeSpan(originalQuoteSpan);
ssb.setSpan(new QuoteSpan(colorBlockQuote, stripeWidth, gapWidth), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return ssb;
}
@NonNull
public static String replaceCheckboxesWithEmojis(@NonNull String content) {
return runForEachCheckbox(content, (line) -> {
for (final var listType : EListType.values()) {
if (CHECKBOX_CHECKED_EMOJI.isPresent()) {
line = line.replace(listType.checkboxChecked, CHECKBOX_CHECKED_EMOJI.get());
line = line.replace(listType.checkboxCheckedUpperCase, CHECKBOX_CHECKED_EMOJI.get());
}
if (CHECKBOX_UNCHECKED_EMOJI.isPresent()) {
line = line.replace(listType.checkboxUnchecked, CHECKBOX_UNCHECKED_EMOJI.get());
}
}
return line;
});
}
@NonNull
private static Optional<String> getCheckboxEmoji(boolean checked) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
final String[] emojis;
// Seriously what the fuck, Samsung?
// https://emojipedia.org/ballot-box-with-x/
if (Build.MANUFACTURER != null && Build.MANUFACTURER.toLowerCase(Locale.getDefault()).contains("samsung")) {
emojis = checked
? new String[]{"", "☑️", "✔️"}
: new String[]{"", "\uD83D\uDD32", "☐️"};
} else {
emojis = checked
? new String[]{"", "", "☑️", "✔️"}
: new String[]{"", "", "\uD83D\uDD32", "☐️"};
}
final var paint = new Paint();
for (String emoji : emojis) {
if (paint.hasGlyph(emoji)) {
return Optional.of(emoji);
}
}
}
return Optional.empty();
}
/**
* Performs the given {@param map} function for each line which contains a checkbox
*/
@NonNull
private static String runForEachCheckbox(@NonNull String markdownString, @NonNull Function<String, String> map) {
final String[] lines = markdownString.split("\n");
boolean isInFencedCodeBlock = false;
int fencedCodeBlockSigns = 0;
for (int i = 0; i < lines.length; i++) {
final var matcher = PATTERN_CODE_FENCE.matcher(lines[i]);
if (matcher.find()) {
final String fence = matcher.group(1);
if (fence != null) {
int currentFencedCodeBlockSigns = fence.length();
if (isInFencedCodeBlock) {
if (currentFencedCodeBlockSigns == fencedCodeBlockSigns) {
isInFencedCodeBlock = false;
fencedCodeBlockSigns = 0;
}
} else {
isInFencedCodeBlock = true;
fencedCodeBlockSigns = currentFencedCodeBlockSigns;
}
}
}
if (!isInFencedCodeBlock) {
if (lineStartsWithCheckbox(lines[i]) && lines[i].trim().length() > EListType.DASH.checkboxChecked.length()) {
lines[i] = map.apply(lines[i]);
}
}
}
return TextUtils.join("\n", lines);
}
public static int getStartOfLine(@NonNull CharSequence s, int cursorPosition) {
int startOfLine = cursorPosition;
while (startOfLine > 0 && s.charAt(startOfLine - 1) != '\n') {
startOfLine--;
}
return startOfLine;
}
public static int getEndOfLine(@NonNull CharSequence s, int cursorPosition) {
int nextLinebreak = s.toString().indexOf('\n', cursorPosition);
if (nextLinebreak > -1) {
return nextLinebreak;
}
return s.length();
}
public static Optional<String> getListItemIfIsEmpty(@NonNull String line) {
final String trimmedLine = line.trim();
// TODO use Java 11 String::repeat
final var builder = new StringBuilder();
final int indention = line.indexOf(trimmedLine);
for (int i = 0; i < indention; i++) {
builder.append(" ");
}
for (final var listType : EListType.values()) {
if (trimmedLine.equals(listType.checkboxUnchecked)) {
return Optional.of(builder.append(listType.checkboxUncheckedWithTrailingSpace).toString());
} else if (trimmedLine.equals(listType.listSymbol)) {
return Optional.of(builder.append(listType.listSymbolWithTrailingSpace).toString());
}
}
final var matcher = PATTERN_ORDERED_LIST_ITEM_EMPTY.matcher(line.substring(indention));
if (matcher.find()) {
return Optional.of(builder.append(matcher.group()).toString());
}
return Optional.empty();
}
public static CharSequence setCheckboxStatus(@NonNull String markdownString, int targetCheckboxIndex, boolean newCheckedState) {
final String[] lines = markdownString.split("\n");
int checkboxIndex = 0;
boolean isInFencedCodeBlock = false;
int fencedCodeBlockSigns = 0;
for (int i = 0; i < lines.length; i++) {
final var matcher = PATTERN_CODE_FENCE.matcher(lines[i]);
if (matcher.find()) {
final String fence = matcher.group(1);
if (fence != null) {
int currentFencedCodeBlockSigns = fence.length();
if (isInFencedCodeBlock) {
if (currentFencedCodeBlockSigns == fencedCodeBlockSigns) {
isInFencedCodeBlock = false;
fencedCodeBlockSigns = 0;
}
} else {
isInFencedCodeBlock = true;
fencedCodeBlockSigns = currentFencedCodeBlockSigns;
}
}
}
if (!isInFencedCodeBlock) {
if (lineStartsWithCheckbox(lines[i]) && lines[i].trim().length() > EListType.DASH.checkboxChecked.length()) {
if (checkboxIndex == targetCheckboxIndex) {
final int indexOfStartingBracket = lines[i].indexOf("[");
final String toggledLine = lines[i].substring(0, indexOfStartingBracket + 1) +
(newCheckedState ? 'x' : ' ') +
lines[i].substring(indexOfStartingBracket + 2);
lines[i] = toggledLine;
break;
}
checkboxIndex++;
}
}
}
return TextUtils.join("\n", lines);
}
public static boolean lineStartsWithCheckbox(@NonNull String line) {
for (EListType listType : EListType.values()) {
if (lineStartsWithCheckbox(line, listType)) {
return true;
}
}
return false;
}
public static boolean lineStartsWithCheckbox(@NonNull String line, @NonNull EListType listType) {
final String trimmedLine = line.trim();
return (trimmedLine.startsWith(listType.checkboxUnchecked) || trimmedLine.startsWith(listType.checkboxChecked) || trimmedLine.startsWith(listType.checkboxCheckedUpperCase));
}
/**
* @return the number of the ordered list item if the line is an ordered list, otherwise -1.
*/
public static Optional<Integer> getOrderedListNumber(@NonNull String line) {
final var matcher = PATTERN_ORDERED_LIST_ITEM.matcher(line);
if (matcher.find()) {
final String groupNumber = matcher.group(1);
if (groupNumber != null) {
try {
return Optional.of(Integer.parseInt(groupNumber));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
}
return Optional.empty();
}
/**
* Modifies the {@param editable} and adds the given {@param punctuation} from
* {@param selectionStart} to {@param selectionEnd} or removes the {@param punctuation} in case
* it already is around the selected part.
*
* @return the new cursor position
*/
public static int togglePunctuation(@NonNull Editable editable, int selectionStart, int selectionEnd, @NonNull String punctuation) {
final String initialString = editable.toString();
if (selectionStart < 0 || selectionStart > initialString.length() || selectionEnd < 0 || selectionEnd > initialString.length()) {
return 0;
}
// handle special case: italic (that damn thing will match like ANYTHING (regarding bold / bold+italic)....)
final boolean isItalic = punctuation.length() == 1 && punctuation.charAt(0) == '*';
if (isItalic) {
final var result = handleItalicEdgeCase(editable, initialString, selectionStart, selectionEnd);
// The result is only present if this actually was an edge case
if (result.isPresent()) {
return result.get();
}
}
// handle the simple cases
final String wildcardRex = "([^" + punctuation.charAt(0) + "])+";
final String punctuationRex = Pattern.quote(punctuation);
final String pattern = isItalic
// in this case let's make optional asterisks around it, so it wont match anything between two (bold+italic)s
? "\\*?\\*?" + punctuationRex + wildcardRex + punctuationRex + "\\*?\\*?"
: punctuationRex + wildcardRex + punctuationRex;
final var searchPattern = Pattern.compile(pattern);
int relevantStart = selectionStart - 2;
relevantStart = Math.max(relevantStart, 0);
int relevantEnd = selectionEnd + 2;
relevantEnd = Math.min(relevantEnd, initialString.length());
final Matcher matcher = searchPattern.matcher(initialString).region(relevantStart, relevantEnd);
// if the matcher matches, it's a remove
if (matcher.find()) {
// this resets the matcher, while keeping the required region
matcher.region(relevantStart, relevantEnd);
final int punctuationLength = punctuation.length();
final var startEnd = new LinkedList<Pair<Integer, Integer>>();
int removedCount = 0;
while (matcher.find()) {
startEnd.add(new Pair<>(matcher.start(), matcher.end()));
removedCount += punctuationLength;
}
// start from the end
Collections.reverse(startEnd);
for (final var item : startEnd) {
deletePunctuation(editable, punctuationLength, item.first, item.second);
}
int offsetAtEnd = 0;
// depending on if the user has selected the markdown chars, we might need to add an offset to the resulting cursor position
if (initialString.substring(Math.max(selectionEnd - punctuationLength + 1, 0), Math.min(selectionEnd + 1, initialString.length())).equals(punctuation) ||
initialString.substring(selectionEnd, Math.min(selectionEnd + punctuationLength, initialString.length())).equals(punctuation)) {
offsetAtEnd = punctuationLength;
}
return selectionEnd - removedCount * 2 + offsetAtEnd;
// ^
// start+end, need to double
}
// do nothing when punctuation is contained only once
if (Pattern.compile(punctuationRex).matcher(initialString).region(selectionStart, selectionEnd).find()) {
return selectionEnd;
}
// nothing returned so far, so it has to be an insertion
return insertPunctuation(editable, selectionStart, selectionEnd, punctuation);
}
private static void deletePunctuation(Editable editable, int punctuationLength, int start, int end) {
editable.delete(end - punctuationLength, end);
editable.delete(start, start + punctuationLength);
}
/**
* @return an {@link Optional<Integer>} of the new cursor position.
* The return value is only {@link Optional#isPresent()}, if this is an italic edge case.
*/
@NonNull
private static Optional<Integer> handleItalicEdgeCase(Editable editable, String editableAsString, int selectionStart, int selectionEnd) {
// look if selection is bold, this is the only edge case afaik
final var searchPattern = Pattern.compile("(^|[^*])" + PATTERN_QUOTE_BOLD_PUNCTUATION + "([^*])*" + PATTERN_QUOTE_BOLD_PUNCTUATION + "([^*]|$)");
// look the selection expansion by 1 is intended, so the NOT '*' has a chance to match. we don't want to match ***blah***
final var matcher = searchPattern.matcher(editableAsString)
.region(Math.max(selectionStart - 1, 0), Math.min(selectionEnd + 1, editableAsString.length()));
if (matcher.find()) {
return Optional.of(insertPunctuation(editable, selectionStart, selectionEnd, "*"));
}
// look around (3 chars) (NOT '*' + "**"). User might have selected the text only
if (matcher.region(Math.max(selectionStart - 3, 0), Math.min(selectionEnd + 3, editableAsString.length())).find()) {
return Optional.of(insertPunctuation(editable, selectionStart, selectionEnd, "*"));
}
return Optional.empty();
}
private static int insertPunctuation(Editable editable, int firstPosition, int secondPosition, String punctuation) {
editable.insert(secondPosition, punctuation);
editable.insert(firstPosition, punctuation);
return secondPosition + punctuation.length();
}
/**
* Inserts a link into the given {@param editable} from {@param selectionStart} to {@param selectionEnd} and uses the {@param clipboardUrl} if available.
*
* @return the new cursor position
*/
public static int insertLink(@NonNull Editable editable, int selectionStart, int selectionEnd, @Nullable String clipboardUrl) {
if (selectionStart == selectionEnd) {
if (selectionStart > 0 && selectionEnd < editable.length()) {
char start = editable.charAt(selectionStart - 1);
char end = editable.charAt(selectionEnd);
if (start == ' ' || end == ' ') {
if (start != ' ') {
editable.insert(selectionStart, " ");
selectionStart += 1;
}
if (end != ' ') {
editable.insert(selectionEnd, " ");
}
editable.insert(selectionStart, "[](" + (clipboardUrl == null ? "" : clipboardUrl) + ")");
return selectionStart + 1;
} else {
while (start != ' ') {
selectionStart--;
start = editable.charAt(selectionStart);
}
selectionStart++;
while (end != ' ') {
selectionEnd++;
end = editable.charAt(selectionEnd);
}
selectionEnd++;
editable.insert(selectionStart, "[");
editable.insert(selectionEnd, "](" + (clipboardUrl == null ? "" : clipboardUrl) + ")");
if (clipboardUrl != null) {
selectionEnd += clipboardUrl.length();
}
return selectionEnd + 2;
}
} else {
editable.insert(selectionStart, "[](" + (clipboardUrl == null ? "" : clipboardUrl) + ")");
return selectionStart + 1;
}
} else {
final boolean textToFormatIsLink = TextUtils.indexOf(editable.subSequence(selectionStart, selectionEnd), "http") == 0;
if (textToFormatIsLink) {
if (clipboardUrl == null) {
editable.insert(selectionEnd, ")");
editable.insert(selectionStart, "[](");
} else {
editable.insert(selectionEnd, "](" + clipboardUrl + ")");
editable.insert(selectionStart, "[");
selectionEnd += clipboardUrl.length();
}
} else {
if (clipboardUrl == null) {
editable.insert(selectionEnd, "]()");
} else {
editable.insert(selectionEnd, "](" + clipboardUrl + ")");
selectionEnd += clipboardUrl.length();
}
editable.insert(selectionStart, "[");
}
return textToFormatIsLink && clipboardUrl == null
? selectionStart + 1
: selectionEnd + 3;
}
}
public static boolean selectionIsInLink(@NonNull CharSequence text, int start, int end) {
final var matcher = PATTERN_MARKDOWN_LINK.matcher(text);
while (matcher.find()) {
if ((start >= matcher.start() && start < matcher.end()) || (end > matcher.start() && end <= matcher.end())) {
return true;
}
}
return false;
}
public static void searchAndColor(@NonNull Spannable editable, @Nullable CharSequence searchText, @Nullable Integer current, @ColorInt int mainColor, @ColorInt int highlightColor, boolean darkTheme) {
if (searchText != null) {
final var m = Pattern
.compile(searchText.toString(), Pattern.CASE_INSENSITIVE | Pattern.LITERAL)
.matcher(editable);
int i = 1;
while (m.find()) {
int start = m.start();
int end = m.end();
editable.setSpan(new SearchSpan(mainColor, highlightColor, (current != null && i == current), darkTheme), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
i++;
}
}
}
/**
* Removes all spans of {@param spanType} from {@param spannable}.
*/
public static <T> void removeSpans(@NonNull Spannable spannable, @SuppressWarnings("SameParameterValue") Class<T> spanType) {
for (final var span : spannable.getSpans(0, spannable.length(), spanType)) {
spannable.removeSpan(span);
}
}
/**
* @return When the content of the {@param textView} is already of type {@link Spannable}, it will cast and return it directly.
* Otherwise it will create a new {@link SpannableString} from the content, set this as new content of the {@param textView} and return it.
*/
public static Spannable getContentAsSpannable(@NonNull TextView textView) {
final var content = textView.getText();
if (content.getClass() == SpannableString.class || content instanceof Spannable) {
return (Spannable) content;
} else {
Log.w(TAG, "Expected " + TextView.class.getSimpleName() + " content to be of type " + Spannable.class.getSimpleName() + ", but was of type " + content.getClass() + ". Search highlighting will be not performant.");
final var spannableContent = new SpannableString(content);
textView.setText(spannableContent, TextView.BufferType.SPANNABLE);
return spannableContent;
}
}
public static String getMarkdownLink(@NonNull String text, @NonNull String url) {
return "[" + text + "](" + url + ")";
}
/**
* Strips all Markdown from {@param s}
*
* @param s Markdown string
* @return Plain text string
*/
@NonNull
public static String removeMarkdown(@Nullable String s) {
if (TextUtils.isEmpty(s)) {
return "";
}
assert s != null;
final String html = RENDERER.render(PARSER.parse(replaceCheckboxesWithEmojis(s)));
final Spanned spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT);
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
return spanned.toString().trim().replaceAll("\n\n", "\n");
}
return spanned.toString().trim();
}
}

View file

@ -1,24 +0,0 @@
package it.niedermann.android.markdown;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownViewer;
public class MarkdownViewerImpl extends MarkwonMarkdownViewer {
public MarkdownViewerImpl(@NonNull Context context) {
super(context);
}
public MarkdownViewerImpl(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MarkdownViewerImpl(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
}

View file

@ -1,95 +0,0 @@
package it.niedermann.android.markdown;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.ImageSpan;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.nextcloud.android.sso.model.SingleSignOnAccount;
import java.util.Map;
@RestrictTo(RestrictTo.Scope.LIBRARY)
public class MentionUtil {
private MentionUtil() {
// Util class
}
/**
* Replaces all mentions in the textView with an avatar and the display name
*
* @param account {@link SingleSignOnAccount} where the users of those mentions belong to
* @param mentions {@link Map} of all mentions that should be substituted, the key is the user id and the value the display name
* @param target target {@link TextView}
*/
public static void setupMentions(@NonNull SingleSignOnAccount account, @NonNull Map<String, String> mentions, @NonNull TextView target) {
final var context = target.getContext();
// Step 1
// Add avatar icons and display names
final var messageBuilder = replaceAtMentionsWithImagePlaceholderAndDisplayName(context, mentions, target.getText());
// Step 2
// Replace avatar icons with real avatars
final var list = messageBuilder.getSpans(0, messageBuilder.length(), MentionSpan.class);
for (final var span : list) {
final int spanStart = messageBuilder.getSpanStart(span);
final int spanEnd = messageBuilder.getSpanEnd(span);
Glide.with(context)
.asBitmap()
.placeholder(R.drawable.ic_person_grey600_24dp)
.load(account.url + "/index.php/avatar/" + messageBuilder.subSequence(spanStart + 1, spanEnd).toString() + "/" + span.getDrawable().getIntrinsicHeight())
.apply(RequestOptions.circleCropTransform())
.into(new CustomTarget<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
messageBuilder.removeSpan(span);
messageBuilder.setSpan(new MentionSpan(context, resource), spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
// silence is gold
}
});
}
target.setText(messageBuilder);
}
private static SpannableStringBuilder replaceAtMentionsWithImagePlaceholderAndDisplayName(@NonNull Context context, @NonNull Map<String, String> mentions, @NonNull CharSequence text) {
final var messageBuilder = new SpannableStringBuilder(text);
for (String userId : mentions.keySet()) {
final String mentionId = "@" + userId;
final String mentionDisplayName = " " + mentions.get(userId);
int index = messageBuilder.toString().lastIndexOf(mentionId);
while (index >= 0) {
messageBuilder.setSpan(new MentionSpan(context, R.drawable.ic_person_grey600_24dp), index, index + mentionId.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
messageBuilder.insert(index + mentionId.length(), mentionDisplayName);
index = messageBuilder.toString().substring(0, index).lastIndexOf(mentionId);
}
}
return messageBuilder;
}
private static class MentionSpan extends ImageSpan {
private MentionSpan(@NonNull Context context, int resourceId) {
super(context, resourceId);
}
private MentionSpan(@NonNull Context context, @NonNull Bitmap bitmap) {
super(context, bitmap);
}
}
}

View file

@ -1,143 +0,0 @@
package it.niedermann.android.markdown.markwon;
import android.content.Context;
import android.os.Build;
import android.text.TextWatcher;
import android.util.AttributeSet;
import android.util.Log;
import android.widget.EditText;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatEditText;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import java.util.function.Consumer;
import io.noties.markwon.Markwon;
import io.noties.markwon.editor.MarkwonEditor;
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.image.ImagesPlugin;
import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin;
import io.noties.markwon.simple.ext.SimpleExtPlugin;
import it.niedermann.android.markdown.MarkdownEditor;
import it.niedermann.android.markdown.markwon.format.ContextBasedFormattingCallback;
import it.niedermann.android.markdown.markwon.format.ContextBasedRangeFormattingCallback;
import it.niedermann.android.markdown.markwon.handler.BlockQuoteEditHandler;
import it.niedermann.android.markdown.markwon.handler.CodeBlockEditHandler;
import it.niedermann.android.markdown.markwon.handler.CodeEditHandler;
import it.niedermann.android.markdown.markwon.handler.HeadingEditHandler;
import it.niedermann.android.markdown.markwon.handler.StrikethroughEditHandler;
import it.niedermann.android.markdown.markwon.plugins.SearchHighlightPlugin;
import it.niedermann.android.markdown.markwon.plugins.ThemePlugin;
import it.niedermann.android.markdown.markwon.textwatcher.CombinedTextWatcher;
import it.niedermann.android.markdown.markwon.textwatcher.SearchHighlightTextWatcher;
public class MarkwonMarkdownEditor extends AppCompatEditText implements MarkdownEditor {
private static final String TAG = MarkwonMarkdownEditor.class.getSimpleName();
@Nullable
private Consumer<CharSequence> listener;
private final MutableLiveData<CharSequence> unrenderedText$ = new MutableLiveData<>();
private final CombinedTextWatcher combinedWatcher;
public MarkwonMarkdownEditor(@NonNull Context context) {
this(context, null);
}
public MarkwonMarkdownEditor(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, android.R.attr.editTextStyle);
}
public MarkwonMarkdownEditor(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
final var markwon = createMarkwonBuilder(context).build();
final var editor = createMarkwonEditorBuilder(markwon).build();
combinedWatcher = new CombinedTextWatcher(editor, this);
addTextChangedListener(combinedWatcher);
setCustomSelectionActionModeCallback(new ContextBasedRangeFormattingCallback(this));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setCustomInsertionActionModeCallback(new ContextBasedFormattingCallback(this));
}
}
private static Markwon.Builder createMarkwonBuilder(@NonNull Context context) {
return Markwon.builder(context)
.usePlugin(ThemePlugin.create(context))
.usePlugin(StrikethroughPlugin.create())
.usePlugin(SimpleExtPlugin.create())
.usePlugin(ImagesPlugin.create())
.usePlugin(MarkwonInlineParserPlugin.create())
.usePlugin(SearchHighlightPlugin.create(context));
}
private static MarkwonEditor.Builder createMarkwonEditorBuilder(@NonNull Markwon markwon) {
return MarkwonEditor.builder(markwon)
.useEditHandler(new EmphasisEditHandler())
.useEditHandler(new StrongEmphasisEditHandler())
.useEditHandler(new StrikethroughEditHandler())
.useEditHandler(new CodeEditHandler())
.useEditHandler(new CodeBlockEditHandler())
.useEditHandler(new BlockQuoteEditHandler())
.useEditHandler(new HeadingEditHandler());
}
@Override
public void setSearchColor(@ColorInt int color) {
final var searchHighlightTextWatcher = combinedWatcher.get(SearchHighlightTextWatcher.class);
if (searchHighlightTextWatcher == null) {
Log.w(TAG, SearchHighlightTextWatcher.class.getSimpleName() + " is not a registered " + TextWatcher.class.getSimpleName());
} else {
searchHighlightTextWatcher.setSearchColor(color);
}
}
@Override
public void setSearchText(@Nullable CharSequence searchText, @Nullable Integer current) {
final var searchHighlightTextWatcher = combinedWatcher.get(SearchHighlightTextWatcher.class);
if (searchHighlightTextWatcher == null) {
Log.w(TAG, SearchHighlightTextWatcher.class.getSimpleName() + " is not a registered " + TextWatcher.class.getSimpleName());
} else {
searchHighlightTextWatcher.setSearchText(searchText, current);
}
}
@Override
public void setMarkdownString(CharSequence text) {
setText(text);
setMarkdownStringModel(text);
}
@Override
public void setMarkdownString(CharSequence text, Runnable afterRender) {
throw new UnsupportedOperationException("This is not available in " + MarkwonMarkdownEditor.class.getSimpleName() + " because the text is getting rendered all the time.");
}
/**
* Updates the current model which matches the rendered state of the editor *without* triggering
* anything of the native {@link EditText}
*/
public void setMarkdownStringModel(CharSequence text) {
unrenderedText$.setValue(text == null ? "" : text.toString());
if (listener != null) {
listener.accept(text);
}
}
@Override
public LiveData<CharSequence> getMarkdownString() {
return unrenderedText$;
}
@Override
public void setMarkdownStringChangedListener(@Nullable Consumer<CharSequence> listener) {
this.listener = listener;
}
}

View file

@ -1,18 +0,0 @@
package it.niedermann.android.markdown.markwon;
import android.content.Context;
import android.content.res.Configuration;
import androidx.annotation.NonNull;
public class MarkwonMarkdownUtil {
private MarkwonMarkdownUtil() {
// Util class
}
public static boolean isDarkThemeActive(@NonNull Context context) {
final int uiMode = context.getResources().getConfiguration().uiMode;
return (uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
}
}

View file

@ -1,192 +0,0 @@
package it.niedermann.android.markdown.markwon;
import android.content.Context;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.function.Function;
import io.noties.markwon.Markwon;
import io.noties.markwon.MarkwonPlugin;
import io.noties.markwon.SoftBreakAddsNewLinePlugin;
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin;
import io.noties.markwon.ext.tables.TableAwareMovementMethod;
import io.noties.markwon.ext.tables.TablePlugin;
import io.noties.markwon.ext.tasklist.TaskListPlugin;
import io.noties.markwon.image.glide.GlideImagesPlugin;
import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.noties.markwon.movement.MovementMethodPlugin;
import io.noties.markwon.simple.ext.SimpleExtPlugin;
import io.noties.markwon.syntax.Prism4jTheme;
import io.noties.markwon.syntax.Prism4jThemeDarkula;
import io.noties.markwon.syntax.Prism4jThemeDefault;
import io.noties.markwon.syntax.SyntaxHighlightPlugin;
import io.noties.prism4j.Prism4j;
import io.noties.prism4j.annotations.PrismBundle;
import it.niedermann.android.markdown.MarkdownEditor;
import it.niedermann.android.markdown.MarkdownUtil;
import it.niedermann.android.markdown.markwon.plugins.CustomGlideStore;
import it.niedermann.android.markdown.markwon.plugins.LinkClickInterceptorPlugin;
import it.niedermann.android.markdown.markwon.plugins.NextcloudMentionsPlugin;
import it.niedermann.android.markdown.markwon.plugins.SearchHighlightPlugin;
import it.niedermann.android.markdown.markwon.plugins.ThemePlugin;
import it.niedermann.android.markdown.markwon.plugins.ToggleableTaskListPlugin;
import static androidx.lifecycle.Transformations.distinctUntilChanged;
@PrismBundle(includeAll = true, grammarLocatorClassName = ".MarkwonGrammarLocator")
public class MarkwonMarkdownViewer extends AppCompatTextView implements MarkdownEditor {
private static final String TAG = MarkwonMarkdownViewer.class.getSimpleName();
private static final Prism4j prism4j = new Prism4j(new MarkwonGrammarLocator());
private Markwon markwon;
@Nullable
private Consumer<CharSequence> listener = null;
private final MutableLiveData<CharSequence> unrenderedText$ = new MutableLiveData<>();
private final ExecutorService renderService;
public MarkwonMarkdownViewer(@NonNull Context context) {
this(context, null);
}
public MarkwonMarkdownViewer(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, android.R.attr.textViewStyle);
}
public MarkwonMarkdownViewer(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.markwon = createMarkwonBuilder(context).build();
this.renderService = Executors.newSingleThreadExecutor();
}
private Markwon.Builder createMarkwonBuilder(@NonNull Context context) {
final Prism4jTheme prism4jTheme = MarkwonMarkdownUtil.isDarkThemeActive(context)
? Prism4jThemeDarkula.create()
: Prism4jThemeDefault.create();
return Markwon.builder(context)
.usePlugin(ThemePlugin.create(context))
.usePlugin(StrikethroughPlugin.create())
.usePlugin(SimpleExtPlugin.create())
.usePlugin(MarkwonInlineParserPlugin.create())
.usePlugin(SearchHighlightPlugin.create(context))
.usePlugin(TablePlugin.create(context))
.usePlugin(TaskListPlugin.create(context))
.usePlugin(LinkifyPlugin.create(true))
.usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create()))
.usePlugin(LinkClickInterceptorPlugin.create())
.usePlugin(GlideImagesPlugin.create(new CustomGlideStore(context)))
.usePlugin(SoftBreakAddsNewLinePlugin.create())
.usePlugin(SyntaxHighlightPlugin.create(prism4j, prism4jTheme))
.usePlugin(new ToggleableTaskListPlugin((toggledCheckboxPosition, newCheckedState) -> {
final var oldUnrenderedText = unrenderedText$.getValue();
if (oldUnrenderedText == null) {
throw new IllegalStateException("Checkbox #" + toggledCheckboxPosition + ", but unrenderedText$ value is null.");
}
final var newUnrenderedText = MarkdownUtil.setCheckboxStatus(oldUnrenderedText.toString(), toggledCheckboxPosition, newCheckedState);
this.setMarkdownString(newUnrenderedText);
}));
}
public Markwon.Builder createMarkwonBuilder(@NonNull Context context, @NonNull Map<String, String> mentions) {
return createMarkwonBuilder(context)
.usePlugin(NextcloudMentionsPlugin.create(context, mentions));
}
@Override
public void registerOnLinkClickCallback(@NonNull Function<String, Boolean> callback) {
final var plugin = this.markwon.getPlugin(LinkClickInterceptorPlugin.class);
if (plugin == null) {
Log.w(TAG, "Tried to register callback, but " + LinkClickInterceptorPlugin.class.getSimpleName() + " is not a registered " + MarkwonPlugin.class.getSimpleName() + ".");
} else {
plugin.registerOnLinkClickCallback(callback);
}
}
@Override
public void setEnabled(boolean enabled) {
final var plugin = this.markwon.getPlugin(ToggleableTaskListPlugin.class);
if (plugin == null) {
Log.w(TAG, "Tried to set enabled state for " + ToggleableTaskListPlugin.class.getSimpleName() + ", but " + ToggleableTaskListPlugin.class.getSimpleName() + " is not a registered " + MarkwonPlugin.class.getSimpleName() + ".");
} else {
plugin.setEnabled(enabled);
}
}
@Override
public void setMarkdownString(CharSequence text) {
setMarkdownString(text, null);
}
@Override
public void setMarkdownString(CharSequence text, Runnable afterRender) {
final var previousText = this.unrenderedText$.getValue();
this.unrenderedText$.setValue(text);
if (listener != null) {
listener.accept(text);
}
if (TextUtils.isEmpty(text)) {
setText(text);
} else {
if (!text.equals(previousText)) {
this.renderService.execute(() -> post(() -> {
this.markwon.setMarkdown(this, text.toString());
if (afterRender != null) {
afterRender.run();
}
}));
}
}
}
@Override
public void setSearchColor(@ColorInt int color) {
final var searchHighlightPlugin = this.markwon.getPlugin(SearchHighlightPlugin.class);
if (searchHighlightPlugin == null) {
Log.w(TAG, SearchHighlightPlugin.class.getSimpleName() + " is not a registered " + MarkwonPlugin.class.getSimpleName());
} else {
searchHighlightPlugin.setSearchColor(color, this);
}
}
@Override
public void setSearchText(@Nullable CharSequence searchText, @Nullable Integer current) {
final var searchHighlightPlugin = this.markwon.getPlugin(SearchHighlightPlugin.class);
if (searchHighlightPlugin == null) {
Log.w(TAG, SearchHighlightPlugin.class.getSimpleName() + " is not a registered " + MarkwonPlugin.class.getSimpleName());
} else {
searchHighlightPlugin.setSearchText(searchText, current, this);
}
}
@Override
public void setMarkdownStringAndHighlightMentions(CharSequence text, @NonNull Map<String, String> mentions) {
this.markwon = createMarkwonBuilder(getContext(), mentions).build();
setMarkdownString(text);
}
@Override
public LiveData<CharSequence> getMarkdownString() {
return distinctUntilChanged(this.unrenderedText$);
}
@Override
public void setMarkdownStringChangedListener(@Nullable Consumer<CharSequence> listener) {
this.listener = listener;
}
}

View file

@ -1,83 +0,0 @@
package it.niedermann.android.markdown.markwon.format;
import android.text.Editable;
import android.util.Log;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import it.niedermann.android.markdown.R;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
import it.niedermann.android.markdown.MarkdownUtil;
import it.niedermann.android.markdown.model.EListType;
import it.niedermann.android.util.ClipboardUtil;
import static it.niedermann.android.markdown.MarkdownUtil.getEndOfLine;
import static it.niedermann.android.markdown.MarkdownUtil.getStartOfLine;
import static it.niedermann.android.markdown.MarkdownUtil.lineStartsWithCheckbox;
public class ContextBasedFormattingCallback implements ActionMode.Callback {
private static final String TAG = ContextBasedFormattingCallback.class.getSimpleName();
private final MarkwonMarkdownEditor editText;
public ContextBasedFormattingCallback(MarkwonMarkdownEditor editText) {
this.editText = editText;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mode.getMenuInflater().inflate(R.menu.context_based_formatting, menu);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
final var text = editText.getText();
if (text != null) {
final int cursorPosition = editText.getSelectionStart();
if (cursorPosition >= 0 && cursorPosition <= text.length()) {
final int startOfLine = getStartOfLine(text, cursorPosition);
final int endOfLine = getEndOfLine(text, cursorPosition);
final String line = text.subSequence(startOfLine, endOfLine).toString();
if (lineStartsWithCheckbox(line)) {
menu.findItem(R.id.checkbox).setVisible(false);
Log.i(TAG, "Hide checkbox menu item because line starts already with checkbox");
}
} else {
Log.e(TAG, "SelectionStart is " + cursorPosition + ". Expected to be between 0 and " + text.length());
}
}
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
final var editable = editText.getText();
if (editable != null) {
final int itemId = item.getItemId();
final int cursorPosition = editText.getSelectionStart();
if (itemId == R.id.checkbox) {
editable.insert(getStartOfLine(editable, cursorPosition), EListType.DASH.checkboxUncheckedWithTrailingSpace);
editText.setMarkdownStringModel(editable);
editText.setSelection(cursorPosition + EListType.DASH.checkboxUncheckedWithTrailingSpace.length());
return true;
} else if (itemId == R.id.link) {
final int newSelection = MarkdownUtil.insertLink(editable, cursorPosition, cursorPosition, ClipboardUtil.INSTANCE.getClipboardURLorNull(editText.getContext()));
editText.setMarkdownStringModel(editable);
editText.setSelection(newSelection);
return true;
}
} else {
Log.e(TAG, "Editable is null");
}
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
// Nothing to do here...
}
}

View file

@ -1,103 +0,0 @@
package it.niedermann.android.markdown.markwon.format;
import android.graphics.Typeface;
import android.text.Editable;
import android.text.SpannableString;
import android.text.style.StyleSpan;
import android.util.Log;
import android.util.SparseIntArray;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import it.niedermann.android.markdown.R;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
import it.niedermann.android.markdown.MarkdownUtil;
import it.niedermann.android.util.ClipboardUtil;
public class ContextBasedRangeFormattingCallback implements ActionMode.Callback {
private static final String TAG = ContextBasedRangeFormattingCallback.class.getSimpleName();
private final MarkwonMarkdownEditor editText;
public ContextBasedRangeFormattingCallback(MarkwonMarkdownEditor editText) {
this.editText = editText;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mode.getMenuInflater().inflate(R.menu.context_based_range_formatting, menu);
final var styleFormatMap = new SparseIntArray();
styleFormatMap.append(R.id.bold, Typeface.BOLD);
styleFormatMap.append(R.id.italic, Typeface.ITALIC);
MenuItem item;
CharSequence title;
SpannableString spannableString;
for (int i = 0; i < styleFormatMap.size(); i++) {
item = menu.findItem(styleFormatMap.keyAt(i));
title = item.getTitle();
spannableString = new SpannableString(title);
spannableString.setSpan(new StyleSpan(styleFormatMap.valueAt(i)), 0, title.length(), 0);
item.setTitle(spannableString);
}
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
final var text = editText.getText();
if (text != null) {
final int selectionStart = editText.getSelectionStart();
final int selectionEnd = editText.getSelectionEnd();
if (selectionStart >= 0 && selectionStart <= text.length()) {
if (MarkdownUtil.selectionIsInLink(text, selectionStart, selectionEnd)) {
menu.findItem(R.id.link).setVisible(false);
Log.i(TAG, "Hide link menu item because the selection is already within a link.");
}
} else {
Log.e(TAG, "SelectionStart is " + selectionStart + ". Expected to be between 0 and " + text.length());
}
}
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
final var editable = editText.getText();
if (editable != null) {
final int itemId = item.getItemId();
final int start = editText.getSelectionStart();
final int end = editText.getSelectionEnd();
if (itemId == R.id.bold) {
final int newSelection = MarkdownUtil.togglePunctuation(editable, start, end, "**");
editText.setMarkdownStringModel(editable);
editText.setSelection(newSelection);
return true;
} else if (itemId == R.id.italic) {
final int newSelection = MarkdownUtil.togglePunctuation(editable, start, end, "*");
editText.setMarkdownStringModel(editable);
editText.setSelection(newSelection);
return true;
} else if (itemId == R.id.link) {
final int newSelection = MarkdownUtil.insertLink(editable, start, end, ClipboardUtil.INSTANCE.getClipboardURLorNull(editText.getContext()));
editText.setMarkdownStringModel(editable);
editText.setSelection(newSelection);
return true;
}
} else {
Log.e(TAG, "Editable is null");
}
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
// Nothing to do here...
}
}

View file

@ -1,33 +0,0 @@
package it.niedermann.android.markdown.markwon.glide;
import androidx.annotation.Px;
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
/**
* @see <a href="https://github.com/noties/Markwon/issues/329#issuecomment-855220315">Source</a>
*/
public class DownsampleWithMaxWidth extends DownsampleStrategy {
@Px
private final int maxWidth;
public DownsampleWithMaxWidth(@Px int maxWidth) {
this.maxWidth = maxWidth;
}
@Override
public float getScaleFactor(int sourceWidth, int sourceHeight, int requestedWidth, int requestedHeight) {
// do not scale down if fits requested dimension
if (sourceWidth < maxWidth) {
return 1F;
}
return (float) maxWidth / sourceWidth;
}
@Override
public SampleSizeRounding getSampleSizeRounding(int sourceWidth, int sourceHeight, int requestedWidth, int requestedHeight) {
// go figure
return SampleSizeRounding.MEMORY;
}
}

View file

@ -1,50 +0,0 @@
package it.niedermann.android.markdown.markwon.handler;
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;
public 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;
}
}

View file

@ -1,46 +0,0 @@
package it.niedermann.android.markdown.markwon.handler;
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.CodeBlockSpan;
import io.noties.markwon.editor.EditHandler;
import io.noties.markwon.editor.MarkwonEditorUtils;
import io.noties.markwon.editor.PersistedSpans;
public class CodeBlockEditHandler implements EditHandler<CodeBlockSpan> {
private MarkwonTheme theme;
@Override
public void init(@NonNull Markwon markwon) {
theme = markwon.configuration().theme();
}
@Override
public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
builder.persistSpan(CodeBlockSpan.class, () -> new CodeBlockSpan(theme));
}
@Override
public void handleMarkdownSpan(@NonNull PersistedSpans persistedSpans, @NonNull Editable editable, @NonNull String input, @NonNull CodeBlockSpan span, int spanStart, int spanTextLength) {
final var delimited = MarkwonEditorUtils.findDelimited(input, spanStart, "```");
if (delimited != null) {
editable.setSpan(
persistedSpans.get(markdownSpanType()),
delimited.start(),
delimited.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
}
@NonNull
@Override
public Class<CodeBlockSpan> markdownSpanType() {
return CodeBlockSpan.class;
}
}

View file

@ -1,53 +0,0 @@
package it.niedermann.android.markdown.markwon.handler;
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;
public 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 var 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;
}
}

View file

@ -1,142 +0,0 @@
package it.niedermann.android.markdown.markwon.handler;
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.HeadingSpan;
import io.noties.markwon.editor.EditHandler;
import io.noties.markwon.editor.PersistedSpans;
public class HeadingEditHandler implements EditHandler<HeadingSpan> {
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(Heading1Span.class, () -> new Heading1Span(theme));
builder.persistSpan(Heading2Span.class, () -> new Heading2Span(theme));
builder.persistSpan(Heading3Span.class, () -> new Heading3Span(theme));
builder.persistSpan(Heading4Span.class, () -> new Heading4Span(theme));
builder.persistSpan(Heading5Span.class, () -> new Heading5Span(theme));
builder.persistSpan(Heading6Span.class, () -> new Heading6Span(theme));
}
@Override
public void handleMarkdownSpan(
@NonNull PersistedSpans persistedSpans,
@NonNull Editable editable,
@NonNull String input,
@NonNull HeadingSpan span,
int spanStart,
int spanTextLength) {
final HeadingSpan newSpan;
switch (span.getLevel()) {
case 1:
newSpan = persistedSpans.get(Heading1Span.class);
break;
case 2:
newSpan = persistedSpans.get(Heading2Span.class);
break;
case 3:
newSpan = persistedSpans.get(Heading3Span.class);
break;
case 4:
newSpan = persistedSpans.get(Heading4Span.class);
break;
case 5:
newSpan = persistedSpans.get(Heading5Span.class);
break;
case 6:
newSpan = persistedSpans.get(Heading6Span.class);
break;
default:
return;
}
final int newStart = getNewSpanStart(input, spanStart);
final int newEnd = findEnd(input, newStart, newSpan.getLevel());
editable.setSpan(
newSpan,
newStart,
newEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
private int findEnd(String input, int searchFrom, int spanLevel) {
int end = searchFrom + spanLevel;
final int strLength = input.length();
while (end < strLength - 1) {
end++;
if (input.charAt(end) == '\n') {
break;
}
}
return end + 1;
}
private int getNewSpanStart(String input, int spanStart) {
int start = spanStart;
while (start >= 0 && input.charAt(start) != '\n') {
start--;
}
start += 1;
return start;
}
@NonNull
@Override
public Class<HeadingSpan> markdownSpanType() {
return HeadingSpan.class;
}
private static class Heading1Span extends HeadingSpan {
public Heading1Span(@NonNull MarkwonTheme theme) {
super(theme, 1);
}
}
private static class Heading2Span extends HeadingSpan {
public Heading2Span(@NonNull MarkwonTheme theme) {
super(theme, 2);
}
}
private static class Heading3Span extends HeadingSpan {
public Heading3Span(@NonNull MarkwonTheme theme) {
super(theme, 3);
}
}
private static class Heading4Span extends HeadingSpan {
public Heading4Span(@NonNull MarkwonTheme theme) {
super(theme, 4);
}
}
private static class Heading5Span extends HeadingSpan {
public Heading5Span(@NonNull MarkwonTheme theme) {
super(theme, 5);
}
}
private static class Heading6Span extends HeadingSpan {
public Heading6Span(@NonNull MarkwonTheme theme) {
super(theme, 6);
}
}
}

View file

@ -1,86 +0,0 @@
package it.niedermann.android.markdown.markwon.handler;
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;
public class LinkEditHandler extends AbstractEditHandler<LinkSpan> {
public interface OnClick {
void onClick(@NonNull View widget, @NonNull String link);
}
private final OnClick onClick;
public 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 var 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);
}
}
}
}

View file

@ -1,44 +0,0 @@
package it.niedermann.android.markdown.markwon.handler;
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;
public 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 var 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;
}
}

View file

@ -1,48 +0,0 @@
package it.niedermann.android.markdown.markwon.plugins;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.RequestManager;
import com.bumptech.glide.request.target.Target;
import io.noties.markwon.image.AsyncDrawable;
import io.noties.markwon.image.glide.GlideImagesPlugin;
import it.niedermann.android.markdown.R;
import it.niedermann.android.markdown.markwon.glide.DownsampleWithMaxWidth;
/**
* <ul>
* <li>Applies downscaling via {@link DownsampleWithMaxWidth} to avoid <a href="https://github.com/stefan-niedermann/nextcloud-notes/issues/1034">issues with large images</a></li>
* <li>Adds a placeholder while loading an image</li>
* <li>Adds a "broken image" placeholder in case of an error</li>
* </ul>
*/
public class CustomGlideStore implements GlideImagesPlugin.GlideStore {
private final RequestManager requestManager;
private final DownsampleWithMaxWidth downsampleWithMaxWidth;
public CustomGlideStore(@NonNull Context context) {
this.requestManager = Glide.with(context);
downsampleWithMaxWidth = new DownsampleWithMaxWidth(context.getResources().getDisplayMetrics().widthPixels);
}
@NonNull
@Override
public RequestBuilder<Drawable> load(@NonNull AsyncDrawable drawable) {
return requestManager
.load(drawable.getDestination())
.downsample(downsampleWithMaxWidth)
.placeholder(R.drawable.ic_baseline_image_24)
.error(R.drawable.ic_baseline_broken_image_24);
}
@Override
public void cancel(@NonNull Target<?> target) {
requestManager.clear(target);
}
}

View file

@ -1,35 +0,0 @@
package it.niedermann.android.markdown.markwon.plugins;
import androidx.annotation.NonNull;
import org.commonmark.node.Link;
import java.util.Collection;
import java.util.LinkedList;
import java.util.function.Function;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.MarkwonPlugin;
import io.noties.markwon.MarkwonSpansFactory;
import io.noties.markwon.core.CoreProps;
import it.niedermann.android.markdown.markwon.span.InterceptedURLSpan;
public class LinkClickInterceptorPlugin extends AbstractMarkwonPlugin {
@NonNull
private final Collection<Function<String, Boolean>> onLinkClickCallbacks = new LinkedList<>();
public static MarkwonPlugin create() {
return new LinkClickInterceptorPlugin();
}
@Override
public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) {
super.configureSpansFactory(builder);
builder.setFactory(Link.class, (configuration, props) -> new InterceptedURLSpan(onLinkClickCallbacks, CoreProps.LINK_DESTINATION.get(props)));
}
public void registerOnLinkClickCallback(@NonNull Function<String, Boolean> callback) {
this.onLinkClickCallbacks.add(callback);
}
}

View file

@ -1,44 +0,0 @@
package it.niedermann.android.markdown.markwon.plugins;
import android.content.Context;
import android.widget.TextView;
import androidx.annotation.NonNull;
import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException;
import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException;
import com.nextcloud.android.sso.helper.SingleAccountHelper;
import java.util.Map;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.MarkwonPlugin;
import static it.niedermann.android.markdown.MentionUtil.setupMentions;
public class NextcloudMentionsPlugin extends AbstractMarkwonPlugin {
@NonNull
private final Context context;
@NonNull
private final Map<String, String> mentions;
private NextcloudMentionsPlugin(@NonNull Context context, @NonNull Map<String, String> mentions) {
this.context = context.getApplicationContext();
this.mentions = mentions;
}
public static MarkwonPlugin create(@NonNull Context context, @NonNull Map<String, String> mentions) {
return new NextcloudMentionsPlugin(context, mentions);
}
@Override
public void afterSetText(@NonNull TextView textView) {
super.afterSetText(textView);
try {
setupMentions(SingleAccountHelper.getCurrentSingleSignOnAccount(context), mentions, textView);
} catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) {
e.printStackTrace();
}
}
}

View file

@ -1,67 +0,0 @@
package it.niedermann.android.markdown.markwon.plugins;
import android.content.Context;
import android.text.Spannable;
import android.text.TextUtils;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.MarkwonPlugin;
import it.niedermann.android.markdown.MarkdownUtil;
import it.niedermann.android.markdown.R;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownUtil;
import it.niedermann.android.markdown.model.SearchSpan;
import static it.niedermann.android.markdown.MarkdownUtil.getContentAsSpannable;
public class SearchHighlightPlugin extends AbstractMarkwonPlugin {
@Nullable
private CharSequence searchText = null;
private Integer current;
@ColorInt
private int color;
@ColorInt
private final int highlightColor;
private final boolean darkTheme;
public SearchHighlightPlugin(@NonNull Context context) {
this.color = ContextCompat.getColor(context, R.color.search_color);
this.highlightColor = ContextCompat.getColor(context, R.color.bg_highlighted);
this.darkTheme = MarkwonMarkdownUtil.isDarkThemeActive(context);
}
public static MarkwonPlugin create(@NonNull Context context) {
return new SearchHighlightPlugin(context);
}
public void setSearchText(@Nullable CharSequence searchText, @Nullable Integer current, @NonNull TextView textView) {
this.current = current;
MarkdownUtil.removeSpans(getContentAsSpannable(textView), SearchSpan.class);
if (TextUtils.isEmpty(searchText)) {
this.searchText = null;
} else {
this.searchText = searchText;
afterSetText(textView);
}
}
public void setSearchColor(@ColorInt int color, @NonNull TextView textView) {
this.color = color;
afterSetText(textView);
}
@Override
public void afterSetText(@NonNull TextView textView) {
super.afterSetText(textView);
if (this.searchText != null) {
final var spannable = getContentAsSpannable(textView);
MarkdownUtil.searchAndColor(spannable, searchText, current, color, highlightColor, darkTheme);
}
}
}

View file

@ -1,35 +0,0 @@
package it.niedermann.android.markdown.markwon.plugins;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.MarkwonPlugin;
import io.noties.markwon.core.MarkwonTheme;
import it.niedermann.android.markdown.R;
public class ThemePlugin extends AbstractMarkwonPlugin {
@NonNull
Context context;
private ThemePlugin(@NonNull Context context) {
this.context = context;
}
public static MarkwonPlugin create(@NonNull Context context) {
return new ThemePlugin(context);
}
@Override
public void configureTheme(@NonNull MarkwonTheme.Builder builder) {
super.configureTheme(builder);
builder
.headingBreakHeight(0)
.codeBlockBackgroundColor(ContextCompat.getColor(context, R.color.bg_code))
.headingTextSizeMultipliers(new float[]{1.45f, 1.35f, 1.25f, 1.15f, 1.1f, 1.05f})
.bulletWidth(context.getResources().getDimensionPixelSize(R.dimen.bullet_point_width));
}
}

View file

@ -1,251 +0,0 @@
package it.niedermann.android.markdown.markwon.plugins;
import static java.util.Comparator.comparingInt;
import android.text.style.ClickableSpan;
import android.util.Range;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import org.commonmark.node.AbstractVisitor;
import org.commonmark.node.Block;
import org.commonmark.node.HardLineBreak;
import org.commonmark.node.Node;
import org.commonmark.node.Paragraph;
import org.commonmark.node.SoftLineBreak;
import org.commonmark.node.Text;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.SpannableBuilder.Span;
import io.noties.markwon.ext.tasklist.TaskListItem;
import io.noties.markwon.ext.tasklist.TaskListProps;
import io.noties.markwon.ext.tasklist.TaskListSpan;
import it.niedermann.android.markdown.MarkdownUtil;
import it.niedermann.android.markdown.markwon.span.ToggleTaskListSpan;
/**
* @see <a href="https://github.com/noties/Markwon/issues/196#issuecomment-751680138">Support from upstream</a>
* @see <a href="https://github.com/noties/Markwon/blob/910bf311dac1bade400616a00ab0c9b7b7ade8cb/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListMutateNestedSample.kt">Original kotlin implementation</a>
*/
public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin {
@NonNull
private final AtomicBoolean enabled = new AtomicBoolean(true);
@NonNull
private final BiConsumer<Integer, Boolean> toggleListener;
public ToggleableTaskListPlugin(@NonNull BiConsumer<Integer, Boolean> toggleListener) {
this.toggleListener = toggleListener;
}
public void setEnabled(boolean enabled) {
this.enabled.set(enabled);
}
/**
* Prepares {@link TaskListSpan}s and marks each one with a {@link ToggleMarkerSpan} in the first step.
* The {@link ToggleMarkerSpan} are different from {@link TaskListSpan}s as they will stop on nested tasks instead of spanning the whole tasks including its subtasks.
*/
@Override
public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) {
builder.on(TaskListItem.class, (visitor, node) -> {
final int length = visitor.length();
visitor.visitChildren(node);
TaskListProps.DONE.set(visitor.renderProps(), node.isDone());
final var spanFactory = visitor.configuration()
.spansFactory()
.get(TaskListItem.class);
final var spans = spanFactory == null ? null :
spanFactory.getSpans(visitor.configuration(), visitor.renderProps());
if (spans != null) {
final TaskListSpan taskListSpan;
if (spans instanceof TaskListSpan[]) {
if (((TaskListSpan[]) spans).length > 0) {
taskListSpan = ((TaskListSpan[]) spans)[0];
} else {
taskListSpan = null;
}
} else if (spans instanceof TaskListSpan) {
taskListSpan = (TaskListSpan) spans;
} else {
taskListSpan = null;
}
final int content = TaskListContextVisitor.contentLength(node);
if (content > 0 && taskListSpan != null) {
// maybe additionally identify this task list (for persistence)
visitor.builder().setSpan(
new ToggleMarkerSpan(taskListSpan),
length,
length + content
);
}
}
SpannableBuilder.setSpans(
visitor.builder(),
spans,
length,
visitor.length()
);
if (visitor.hasNext(node)) {
visitor.ensureNewLine();
}
});
}
/**
* Adds for each symbolic {@link ToggleMarkerSpan} an actual {@link ToggleTaskListSpan}s respecting existing {@link ClickableSpan}s.
*/
@Override
public void afterRender(@NonNull Node node, @NonNull MarkwonVisitor visitor) {
super.afterRender(node, visitor);
final var markerSpans = getSortedSpans(visitor.builder(), ToggleMarkerSpan.class, 0, visitor.builder().length());
for (int position = 0; position < markerSpans.size(); position++) {
final var markerSpan = markerSpans.get(position);
final int start = markerSpan.start;
final int end = markerSpan.end;
final var freeRanges = findFreeRanges(visitor.builder(), start, end);
for (Range<Integer> freeRange : freeRanges) {
visitor.builder().setSpan(
new ToggleTaskListSpan(enabled, toggleListener, ((ToggleMarkerSpan) markerSpan.what).getTaskListSpan(), position),
freeRange.getLower(), freeRange.getUpper());
}
}
}
/**
* Removes {@link ToggleMarkerSpan}s from {@param textView}.
*/
@Override
public void afterSetText(@NonNull TextView textView) {
super.afterSetText(textView);
final var spannable = MarkdownUtil.getContentAsSpannable(textView);
for (final var span : spannable.getSpans(0, spannable.length(), ToggleMarkerSpan.class)) {
spannable.removeSpan(span);
}
textView.setText(spannable);
}
/**
* @return a {@link List} of {@link Range}s in the given {@param spanned} from {@param start} to {@param end} which is <strong>not</strong> taken for a {@link ClickableSpan}.
*/
@NonNull
private static Collection<Range<Integer>> findFreeRanges(@NonNull SpannableBuilder builder, int start, int end) {
final List<Range<Integer>> freeRanges;
final var clickableSpans = getSortedSpans(builder, ClickableSpan.class, start, end);
if (clickableSpans.size() > 0) {
freeRanges = new LinkedList<>();
int from = start;
for (final var clickableSpan : clickableSpans) {
final int clickableStart = clickableSpan.start;
final int clickableEnd = clickableSpan.end;
if (from < clickableStart) {
freeRanges.add(new Range<>(from, clickableStart));
}
from = clickableEnd;
}
if (clickableSpans.size() > 0) {
final int lastUpperBlocker = clickableSpans.get(clickableSpans.size() - 1).end;
if (lastUpperBlocker < end) {
freeRanges.add(new Range<>(lastUpperBlocker, end));
}
}
} else if (start == end) {
freeRanges = Collections.emptyList();
} else {
freeRanges = Collections.singletonList(new Range<>(start, end));
}
return freeRanges;
}
/**
* @return a {@link List} of {@link Span}s holding {@param type}s, sorted ascending by the span start.
*/
private static <T> List<Span> getSortedSpans(@NonNull SpannableBuilder builder, @NonNull Class<T> type, int start, int end) {
return builder.getSpans(start, end)
.stream()
.filter(span -> type.isInstance(span.what))
.sorted(comparingInt(o -> o.start))
.collect(Collectors.toList());
}
private static final class TaskListContextVisitor extends AbstractVisitor {
private int contentLength = 0;
static int contentLength(Node node) {
final var visitor = new TaskListContextVisitor();
visitor.visitChildren(node);
return visitor.contentLength;
}
@Override
public void visit(Text text) {
super.visit(text);
contentLength += text.getLiteral().length();
}
// NB! if count both soft and hard breaks as having length of 1
@Override
public void visit(SoftLineBreak softLineBreak) {
super.visit(softLineBreak);
contentLength += 1;
}
// NB! if count both soft and hard breaks as having length of 1
@Override
public void visit(HardLineBreak hardLineBreak) {
super.visit(hardLineBreak);
contentLength += 1;
}
@Override
protected void visitChildren(Node parent) {
var node = parent.getFirstChild();
while (node != null) {
// A subclass of this visitor might modify the node, resulting in getNext returning a different node or no
// node after visiting it. So get the next node before visiting.
final var next = node.getNext();
if (node instanceof Block && !(node instanceof Paragraph)) {
break;
}
node.accept(this);
node = next;
}
}
}
/**
* Helper class which holds an {@link TaskListSpan} but does not include the range of child {@link TaskListSpan}s.
*/
@VisibleForTesting
static final class ToggleMarkerSpan {
@NonNull
private final TaskListSpan taskListSpan;
private ToggleMarkerSpan(@NonNull TaskListSpan taskListSpan) {
this.taskListSpan = taskListSpan;
}
@NonNull
private TaskListSpan getTaskListSpan() {
return taskListSpan;
}
}
}

View file

@ -1,46 +0,0 @@
package it.niedermann.android.markdown.markwon.span;
import android.text.Spanned;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import java.util.Collection;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Function;
public class InterceptedURLSpan extends URLSpan {
private static final String TAG = InterceptedURLSpan.class.getSimpleName();
@NonNull
private final Collection<Function<String, Boolean>> onLinkClickCallbacks;
private final ExecutorService executor = Executors.newCachedThreadPool();
public InterceptedURLSpan(@NonNull Collection<Function<String, Boolean>> onLinkClickCallbacks, String url) {
super(url);
this.onLinkClickCallbacks = onLinkClickCallbacks;
}
@Override
public void onClick(View widget) {
if (onLinkClickCallbacks.size() > 0) {
executor.submit(() -> {
for (final var callback : onLinkClickCallbacks) {
try {
if (callback.apply(getURL())) {
return;
}
} catch (Throwable t) {
Log.w(TAG, t.getMessage(), t);
}
}
super.onClick(widget);
});
} else {
super.onClick(widget);
}
}
}

View file

@ -1,46 +0,0 @@
package it.niedermann.android.markdown.markwon.span;
import android.text.TextPaint;
import android.text.style.ClickableSpan;
import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
import io.noties.markwon.ext.tasklist.TaskListSpan;
public class ToggleTaskListSpan extends ClickableSpan {
private static final String TAG = ToggleTaskListSpan.class.getSimpleName();
private final AtomicBoolean enabled;
private final BiConsumer<Integer, Boolean> toggleListener;
private final TaskListSpan span;
private final int position;
public ToggleTaskListSpan(@NonNull AtomicBoolean enabled, @NonNull BiConsumer<Integer, Boolean> toggleListener, @NonNull TaskListSpan span, int position) {
this.enabled = enabled;
this.toggleListener = toggleListener;
this.span = span;
this.position = position;
}
@Override
public void onClick(@NonNull View widget) {
if (enabled.get()) {
span.setDone(!span.isDone());
widget.invalidate();
toggleListener.accept(position, span.isDone());
} else {
Log.w(TAG, "Prevented toggling checkbox because the view is disabled");
}
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
// NoOp to remove underline text decoration
}
}

View file

@ -1,123 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.text.Editable;
import android.text.TextWatcher;
import androidx.annotation.NonNull;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
import it.niedermann.android.markdown.model.EListType;
import static it.niedermann.android.markdown.MarkdownUtil.getEndOfLine;
import static it.niedermann.android.markdown.MarkdownUtil.getListItemIfIsEmpty;
import static it.niedermann.android.markdown.MarkdownUtil.getOrderedListNumber;
import static it.niedermann.android.markdown.MarkdownUtil.getStartOfLine;
import static it.niedermann.android.markdown.MarkdownUtil.lineStartsWithCheckbox;
/**
* Automatically continues lists and checkbox lists when pressing enter
*/
public class AutoContinuationTextWatcher extends InterceptorTextWatcher {
@NonNull
private final MarkwonMarkdownEditor editText;
private CharSequence customText = null;
private CharSequence oldText = null;
private boolean isInsert = true;
private int sequenceStart = 0;
private static final Pattern REGEX_WHITESPACES = Pattern.compile("^\\s*");
public AutoContinuationTextWatcher(@NonNull TextWatcher originalWatcher, @NonNull MarkwonMarkdownEditor editText) {
super(originalWatcher);
this.editText = editText;
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (count > 0) {
final var inserted = getInsertedString(s, start, before, count);
if (inserted.length() > 0 && inserted.charAt(inserted.length() - 1) == '\n') {
handleNewlineInserted(s, start, count);
}
}
oldText = s.toString();
originalWatcher.onTextChanged(s, start, before, count);
}
@Override
public void afterTextChanged(Editable s) {
if (customText != null) {
final var customText = this.customText;
this.customText = null;
if (isInsert) {
insertCustomText(s, customText);
} else {
deleteCustomText(s, customText);
}
} else {
originalWatcher.afterTextChanged(s);
}
editText.setMarkdownStringModel(s);
}
private CharSequence getInsertedString(CharSequence newText, int start, int before, int count) {
if (newText != null && newText.length() > (oldText == null ? 0 : oldText.length())) {
// character added
final int position = start + before;
return newText.subSequence(position, position + count - before);
}
return "";
}
private void deleteCustomText(Editable s, CharSequence customText) {
s.replace(sequenceStart, Math.min(sequenceStart + customText.length() + 1, s.length()), "\n");
editText.setSelection(sequenceStart + 1);
}
private void insertCustomText(Editable s, CharSequence customText) {
s.insert(sequenceStart, customText);
}
private void handleNewlineInserted(CharSequence originalSequence, int start, int count) {
final var s = originalSequence.subSequence(0, originalSequence.length());
final int startOfLine = getStartOfLine(s, start);
final String line = s.subSequence(startOfLine, getEndOfLine(s, start)).toString();
final var emptyListString = getListItemIfIsEmpty(line);
if (emptyListString.isPresent()) {
customText = emptyListString.get();
isInsert = false;
sequenceStart = startOfLine;
} else {
final var builder = new StringBuilder();
final var matcher = REGEX_WHITESPACES.matcher(line);
if (matcher.find()) {
builder.append(matcher.group());
}
final String trimmedLine = line.trim();
for (final var listType : EListType.values()) {
final boolean isCheckboxList = lineStartsWithCheckbox(trimmedLine, listType);
final boolean isPlainList = !isCheckboxList && trimmedLine.startsWith(listType.listSymbolWithTrailingSpace);
if (isPlainList || isCheckboxList) {
builder.append(isPlainList ? listType.listSymbolWithTrailingSpace : listType.checkboxUncheckedWithTrailingSpace);
customText = builder;
isInsert = true;
sequenceStart = start + count;
return;
}
}
final var orderedListNumber = getOrderedListNumber(trimmedLine);
if (orderedListNumber.isPresent()) {
customText = builder.append(orderedListNumber.get() + 1).append(". ");
isInsert = true;
sequenceStart = start + count;
}
}
}
}

View file

@ -1,49 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.text.Editable;
import android.text.TextWatcher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.HashMap;
import java.util.concurrent.Executors;
import io.noties.markwon.editor.MarkwonEditor;
import io.noties.markwon.editor.MarkwonEditorTextWatcher;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
public class CombinedTextWatcher extends HashMap<Class<?>, TextWatcher> implements TextWatcher {
private final TextWatcher watcher;
@SuppressWarnings("ConstantConditions")
public CombinedTextWatcher(@NonNull MarkwonEditor editor, @NonNull MarkwonMarkdownEditor editText) {
put(MarkwonEditorTextWatcher.class, MarkwonEditorTextWatcher.withPreRender(editor, Executors.newSingleThreadExecutor(), editText));
put(AutoContinuationTextWatcher.class, new AutoContinuationTextWatcher(get(MarkwonEditorTextWatcher.class), editText));
put(LowerIndentionTextWatcher.class, new LowerIndentionTextWatcher(get(AutoContinuationTextWatcher.class), editText));
put(SearchHighlightTextWatcher.class, new SearchHighlightTextWatcher(get(LowerIndentionTextWatcher.class), editText));
watcher = get(SearchHighlightTextWatcher.class);
}
@SuppressWarnings({"unchecked"})
@Nullable
public <T> T get(@Nullable Class<T> key) {
return (T) super.get(key);
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
watcher.beforeTextChanged(s, start, count, after);
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
watcher.onTextChanged(s, start, before, count);
}
@Override
public void afterTextChanged(Editable s) {
watcher.afterTextChanged(s);
}
}

View file

@ -1,31 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.text.Editable;
import android.text.TextWatcher;
import androidx.annotation.NonNull;
abstract public class InterceptorTextWatcher implements TextWatcher {
@NonNull
protected final TextWatcher originalWatcher;
public InterceptorTextWatcher(@NonNull TextWatcher originalWatcher) {
this.originalWatcher = originalWatcher;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
this.originalWatcher.beforeTextChanged(s, start, count, after);
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
this.originalWatcher.onTextChanged(s, start, before, count);
}
@Override
public void afterTextChanged(Editable s) {
this.originalWatcher.afterTextChanged(s);
}
}

View file

@ -1,97 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.text.Editable;
import android.text.TextWatcher;
import androidx.annotation.NonNull;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
import it.niedermann.android.markdown.model.EListType;
import static it.niedermann.android.markdown.MarkdownUtil.getEndOfLine;
import static it.niedermann.android.markdown.MarkdownUtil.getStartOfLine;
/**
* Automatically lowers indention when pressing <kbd>Backspace</kbd> on lists and check lists
*/
public class LowerIndentionTextWatcher extends InterceptorTextWatcher {
@NonNull
private final MarkwonMarkdownEditor editText;
private boolean backspacePressed = false;
private int cursor = 0;
public LowerIndentionTextWatcher(@NonNull TextWatcher originalWatcher, @NonNull MarkwonMarkdownEditor editText) {
super(originalWatcher);
this.editText = editText;
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (count == 0 && before == 1) {
if (editText.getSelectionStart() == editText.getSelectionEnd()) {
backspacePressed = true;
cursor = start;
}
}
originalWatcher.onTextChanged(s, start, before, count);
}
@Override
public void afterTextChanged(Editable editable) {
if (backspacePressed) {
if (!handleBackspace(editable, cursor)) {
originalWatcher.afterTextChanged(editable);
}
backspacePressed = false;
} else {
originalWatcher.afterTextChanged(editable);
}
editText.setMarkdownStringModel(editable);
}
private boolean handleBackspace(@NonNull Editable editable, int cursor) {
final int lineStart = getStartOfLine(editable, cursor);
final int lineEnd = getEndOfLine(editable, cursor);
// The cursor must be at the end of the line to automatically continue
if (cursor != lineEnd) {
return false;
}
final String line = editable.subSequence(lineStart, lineEnd).toString();
final String trimmedLine = line.trim();
// There must be no content in this list item to automatically continue
if ((line.indexOf(trimmedLine) + trimmedLine.length()) < line.length()) {
return false;
}
for (final var listType : EListType.values()) {
if (listType.listSymbol.equals(trimmedLine)) {
if (trimmedLine.length() == EListType.DASH.listSymbol.length()) {
return lowerIndention(editable, line, lineStart, lineEnd);
}
} else if (listType.checkboxUnchecked.equals(trimmedLine) || listType.checkboxChecked.equals(trimmedLine)) {
if (trimmedLine.length() == EListType.DASH.checkboxUnchecked.length()) {
return lowerIndention(editable, line, lineStart, lineEnd);
}
}
}
return false;
}
private boolean lowerIndention(@NonNull Editable editable, String line, int lineStart, int lineEnd) {
if (line.startsWith(" ")) {
editable.insert(lineEnd, " ");
editable.replace(lineStart, lineStart + 2, "");
return true;
} else if (line.startsWith(" ")) {
editable.insert(lineEnd, " ");
editable.replace(lineStart, lineStart + 1, "");
return true;
}
return false;
}
}

View file

@ -1,67 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.content.Context;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import it.niedermann.android.markdown.MarkdownUtil;
import it.niedermann.android.markdown.R;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownUtil;
import it.niedermann.android.markdown.model.SearchSpan;
public class SearchHighlightTextWatcher extends InterceptorTextWatcher {
private final MarkwonMarkdownEditor editText;
@Nullable
private CharSequence searchText;
private Integer current;
@ColorInt
private int color;
@ColorInt
private final int highlightColor;
private final boolean darkTheme;
public SearchHighlightTextWatcher(@NonNull TextWatcher originalWatcher, @NonNull MarkwonMarkdownEditor editText) {
super(originalWatcher);
this.editText = editText;
final var context = editText.getContext();
this.color = ContextCompat.getColor(context, R.color.search_color);
this.highlightColor = ContextCompat.getColor(context, R.color.bg_highlighted);
this.darkTheme = MarkwonMarkdownUtil.isDarkThemeActive(context);
}
public void setSearchText(@Nullable CharSequence searchText, @Nullable Integer current) {
this.current = current;
if (TextUtils.isEmpty(searchText)) {
this.searchText = null;
final var text = editText.getText();
if (text != null) {
MarkdownUtil.removeSpans(text, SearchSpan.class);
}
} else {
this.searchText = searchText;
afterTextChanged(editText.getText());
}
}
public void setSearchColor(@ColorInt int color) {
this.color = color;
afterTextChanged(editText.getText());
}
@Override
public void afterTextChanged(Editable s) {
originalWatcher.afterTextChanged(s);
if (searchText != null) {
MarkdownUtil.removeSpans(s, SearchSpan.class);
MarkdownUtil.searchAndColor(s, searchText, current, color, highlightColor, darkTheme);
}
}
}

View file

@ -1,23 +0,0 @@
package it.niedermann.android.markdown.model;
public enum EListType {
STAR('*'),
DASH('-'),
PLUS('+');
public final String listSymbol;
public final String listSymbolWithTrailingSpace;
public final String checkboxChecked;
public final String checkboxCheckedUpperCase;
public final String checkboxUnchecked;
public final String checkboxUncheckedWithTrailingSpace;
EListType(char listSymbol) {
this.listSymbol = String.valueOf(listSymbol);
this.listSymbolWithTrailingSpace = listSymbol + " ";
this.checkboxChecked = listSymbolWithTrailingSpace + "[x]";
this.checkboxCheckedUpperCase = listSymbolWithTrailingSpace + "[X]";
this.checkboxUnchecked = listSymbolWithTrailingSpace + "[ ]";
this.checkboxUncheckedWithTrailingSpace = checkboxUnchecked + " ";
}
}

View file

@ -1,71 +0,0 @@
package it.niedermann.android.markdown.model;
import android.graphics.Color;
import android.text.TextPaint;
import android.text.style.MetricAffectingSpan;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import it.niedermann.android.util.ColorUtil;
public class SearchSpan extends MetricAffectingSpan {
private final boolean current;
@ColorInt
private final int mainColor;
@ColorInt
private final int highlightColor;
private final boolean darkTheme;
public SearchSpan(@ColorInt int mainColor, @ColorInt int highlightColor, boolean current, boolean darkTheme) {
this.mainColor = mainColor;
this.current = current;
this.highlightColor = highlightColor;
this.darkTheme = darkTheme;
}
@Override
public void updateDrawState(TextPaint tp) {
if (current) {
if (darkTheme) {
if (ColorUtil.INSTANCE.isColorDark(mainColor)) {
tp.bgColor = Color.WHITE;
tp.setColor(mainColor);
} else {
tp.bgColor = mainColor;
tp.setColor(Color.BLACK);
}
} else {
if (ColorUtil.INSTANCE.isColorDark(mainColor)) {
tp.bgColor = mainColor;
tp.setColor(Color.WHITE);
} else {
if (ColorUtil.INSTANCE.getContrastRatio(mainColor, highlightColor) > 3d) {
tp.bgColor = highlightColor;
} else {
tp.bgColor = Color.BLACK;
}
tp.setColor(mainColor);
}
}
} else {
tp.bgColor = highlightColor;
if (ColorUtil.INSTANCE.getContrastRatio(mainColor, highlightColor) > 3d) {
tp.setColor(mainColor);
} else {
if (darkTheme) {
tp.setColor(Color.WHITE);
} else {
tp.setColor(Color.BLACK);
}
}
}
tp.setFakeBoldText(true);
}
@Override
public void updateMeasureState(@NonNull TextPaint tp) {
tp.setFakeBoldText(true);
}
}

View file

@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#757575"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M21,5v6.59l-3,-3.01 -4,4.01 -4,-4 -4,4 -3,-3.01L3,5c0,-1.1 0.9,-2 2,-2h14c1.1,0 2,0.9 2,2zM18,11.42l3,3.01L21,19c0,1.1 -0.9,2 -2,2L5,21c-1.1,0 -2,-0.9 -2,-2v-6.58l3,2.99 4,-4 4,4 4,-3.99z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#757575"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
</vector>

View file

@ -1,11 +0,0 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#757575"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#FF000000"
android:pathData="M15.6,10.79c0.97,-0.67 1.65,-1.77 1.65,-2.79 0,-2.26 -1.75,-4 -4,-4L7,4v14h7.04c2.09,0 3.71,-1.7 3.71,-3.79 0,-1.52 -0.86,-2.82 -2.15,-3.42zM10,6.5h3c0.83,0 1.5,0.67 1.5,1.5s-0.67,1.5 -1.5,1.5h-3v-3zM13.5,15.5L10,15.5v-3h3.5c0.83,0 1.5,0.67 1.5,1.5s-0.67,1.5 -1.5,1.5z" />
</vector>

View file

@ -1,11 +0,0 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#757575"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#FF000000"
android:pathData="M10,4v3h2.21l-3.42,8H6v3h8v-3h-2.21l3.42,-8H18V4z" />
</vector>

View file

@ -1,11 +0,0 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#757575"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#FF000000"
android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z" />
</vector>

View file

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#757575"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/checkbox"
android:title="@string/simple_checkbox"
app:showAsAction="ifRoom" />
<item
android:id="@+id/link"
android:icon="@drawable/ic_insert_link_black_24dp"
android:title="@string/simple_link"
app:showAsAction="ifRoom" />
</menu>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/italic"
android:icon="@drawable/ic_format_italic_black_24dp"
android:title="@string/simple_italic"
app:showAsAction="ifRoom" />
<item
android:id="@+id/bold"
android:icon="@drawable/ic_format_bold_black_24dp"
android:title="@string/simple_bold"
app:showAsAction="ifRoom" />
<item
android:id="@+id/link"
android:icon="@drawable/ic_insert_link_black_24dp"
android:title="@string/simple_link"
app:showAsAction="ifRoom" />
</menu>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg_highlighted">#2a2a2a</color>
<color name="bg_code">#11ffffff</color>
<color name="block_quote">#666</color>
</resources>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="search_color">#0082C9</color>
<color name="bg_highlighted">#eee</color>
<color name="bg_code">#0e000000</color>
<color name="block_quote">#aaa</color>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="bullet_point_width">6dp</dimen>
</resources>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="simple_checkbox">Checkbox</string>
<string name="simple_bold">Bold</string>
<string name="simple_link">Link</string>
<string name="simple_italic">Italic</string>
</resources>

View file

@ -1,142 +0,0 @@
package it.niedermann.android.markdown;
import androidx.core.text.HtmlCompat;
import junit.framework.TestCase;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
@RunWith(RobolectricTestRunner.class)
public class ListTagHandlerTest extends TestCase {
//language=html
private static final String SAMPLE_HTML_WIDGET_TEST = "<h1>Widget-Test</h1>" +
"<p><a href=\"https://nextcloud.com\">Link</a></p>" +
"<p><strong>bold</strong> <em>italic</em> <em><strong>itabold</strong></em> ~~strike~~</p>" +
"<ol start=\"3\">" +
"<li>Item" +
"<ol>" +
"<li>Subitem</li>" +
"<li>Subitem" +
"<ul>" +
"<li>Subi</li>" +
"<li>Subi</li>" +
"</ul>" +
"</li>" +
"<li>Test</li>" +
"</ol>" +
"</li>" +
"<li>Item</li>" +
"<li>Item</li>" +
"</ol>" +
"<ul>" +
"<li>Unordered</li>" +
"<li>Unordered</li>" +
"</ul>" +
"<p>☐ Unchecked<br>☒ Checked</p>";
//language=html
private static final String SAMPLE_HTML_MARKDOWN_SYNTAX =
"<h2>This is a header.</h2>" +
"<ol>" +
"<li>This is the first list item.</li>" +
"<li>This is the second list item.</li>" +
"</ol>" +
"<p>Here's some example code:</p>" +
"<pre><code>return shell_exec(&quot;echo $input | $markdown_script&quot;);" +
"</code></pre>" +
"</blockquote>" +
"<p>Any decent text editor should make email-style quoting easy. For<br>example, with BBEdit, you can make a selection and choose Increase<br>Quote Level from the Text menu.</p>" +
"<h3>Lists</h3>" +
"<p>Markdown supports ordered (numbered) and unordered (bulleted) lists.</p>" +
"<p>Unordered lists use asterisks, pluses, and hyphens -- interchangably<br>-- as list markers:</p>" +
"<ul>" +
"<li>Red</li>" +
"<li>Green</li>" +
"<li>Blue</li>" +
"</ul>" +
"<p>is equivalent to:</p>" +
"<ul>" +
"<li>Red</li>" +
"<li>Green</li>" +
"<li>Blue</li>" +
"</ul>" +
"<p>and:</p>" +
"<ul>" +
"<li>Red</li>" +
"<li>Green</li>" +
"<li>Blue</li>" +
"</ul>" +
"<p>Ordered lists use numbers followed by periods:</p>" +
"<ol>" +
"<li>Bird</li>" +
"<li>McHale</li>" +
"<li>Parish</li>" +
"</ol>" +
"<p>It's important to note that the actual numbers you use to mark the<br>list have no effect on the HTML output Markdown produces. The HTML<br>Markdown produces from the above list is:</p>" +
"<p>If you instead wrote the list in Markdown like this:</p>" +
"<ol>" +
"<li>Bird</li>" +
"<li>McHale</li>" +
"<li>Parish</li>" +
"</ol>" +
"<p>or even:</p>" +
"<ol start=\"3\">" +
"<li>Bird</li>" +
"<li>McHale</li>" +
"<li>Parish</li>" +
"</ol>";
@Test
public void testMarkOrderedListTags() {
assertTrue(SAMPLE_HTML_WIDGET_TEST.contains("<ol start=\"3\">"));
assertTrue(SAMPLE_HTML_WIDGET_TEST.contains("</ol>"));
assertTrue(SAMPLE_HTML_WIDGET_TEST.contains("</ul>"));
assertTrue(SAMPLE_HTML_WIDGET_TEST.contains("</ul>"));
assertTrue(SAMPLE_HTML_WIDGET_TEST.contains("</li>"));
assertTrue(SAMPLE_HTML_WIDGET_TEST.contains("</li>"));
final String markedSampleHtml = ListTagHandler.prepareTagHandling(SAMPLE_HTML_WIDGET_TEST);
assertFalse(markedSampleHtml.contains("<ol start=\"3\">"));
assertFalse(markedSampleHtml.contains("</ol>"));
assertFalse(markedSampleHtml.contains("</ul>"));
assertFalse(markedSampleHtml.contains("</ul>"));
assertFalse(markedSampleHtml.contains("</li>"));
assertFalse(markedSampleHtml.contains("</li>"));
}
@Test
public void testHandleTag() {
final var handler = new ListTagHandler();
assertEquals("\n• Item ", HtmlCompat.fromHtml(ListTagHandler.prepareTagHandling("<ul><li>Item</li></ul>"), 0, null, handler).toString());
final String[] lines = HtmlCompat.fromHtml(ListTagHandler.prepareTagHandling(SAMPLE_HTML_WIDGET_TEST), 0, null, handler).toString().split("\n");
assertEquals("Widget-Test", lines[0]);
assertEquals("", lines[1]);
assertEquals("Link", lines[2]);
assertEquals("", lines[3]);
assertEquals("bold italic itabold ~~strike~~", lines[4]);
assertEquals("", lines[5]);
assertEquals("", lines[6]);
assertEquals("1. Item ", lines[7]);
assertEquals("\t\t1. Subitem ", lines[8]);
assertEquals("\t\t2. Subitem ", lines[9]);
assertEquals("\t\t\t\t• Subi ", lines[10]);
assertEquals("\t\t\t\t• Subi ", lines[11]);
assertEquals("\t\t3. Test ", lines[12]);
assertEquals("2. Item ", lines[13]);
assertEquals("3. Item ", lines[14]);
assertEquals("• Unordered ", lines[15]);
assertEquals("• Unordered ", lines[16]);
assertEquals("", lines[17]);
assertEquals("☐ Unchecked", lines[18]);
assertEquals("☒ Checked", lines[19]);
HtmlCompat.fromHtml(ListTagHandler.prepareTagHandling(SAMPLE_HTML_MARKDOWN_SYNTAX), 0, null, handler);
}
}

View file

@ -1,829 +0,0 @@
package it.niedermann.android.markdown;
import android.graphics.Color;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import junit.framework.TestCase;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import it.niedermann.android.markdown.model.EListType;
import it.niedermann.android.markdown.model.SearchSpan;
@RunWith(RobolectricTestRunner.class)
public class MarkdownUtilTest extends TestCase {
@Test
public void testGetStartOfLine() {
//language=md
final var test = new StringBuilder(
"# Test-Note\n" + // line start 0
"\n" + // line start 12
"- [ ] this is a test note\n" + // line start 13
"- [x] test\n" + // line start 39
"[test](https://example.com)\n" + // line start 50
"\n" + // line start 77
"\n" // line start 78
);
for (int i = 0; i < test.length(); i++) {
int startOfLine = MarkdownUtil.getStartOfLine(test, i);
if (i <= 11) {
assertEquals(0, startOfLine);
} else if (i <= 12) {
assertEquals(12, startOfLine);
} else if (i <= 38) {
assertEquals(13, startOfLine);
} else if (i <= 49) {
assertEquals(39, startOfLine);
} else if (i <= 77) {
assertEquals(50, startOfLine);
} else if (i <= 78) {
assertEquals(78, startOfLine);
} else if (i <= 79) {
assertEquals(79, startOfLine);
}
}
}
@Test
public void testGetEndOfLine() {
//language=md
final var test = "# Test-Note\n" + // line 0 - 11
"\n" + // line 12 - 12
"- [ ] this is a test note\n" + // line 13 - 38
"- [x] test\n" + // line start 39 - 49
"[test](https://example.com)\n" + // line 50 - 77
"\n" + // line 77 - 78
"\n"; // line 78 - 79
for (int i = 0; i < test.length(); i++) {
int endOfLine = MarkdownUtil.getEndOfLine(test, i);
if (i <= 11) {
assertEquals(11, endOfLine);
} else if (i <= 12) {
assertEquals(12, endOfLine);
} else if (i <= 38) {
assertEquals(38, endOfLine);
} else if (i <= 49) {
assertEquals(49, endOfLine);
} else if (i <= 77) {
assertEquals(77, endOfLine);
} else if (i <= 78) {
assertEquals(78, endOfLine);
} else if (i <= 79) {
assertEquals(79, endOfLine);
}
}
assertEquals(3, MarkdownUtil.getEndOfLine(" - ", 2));
}
@Test
public void testGetMarkdownLink() {
assertEquals("[Foo](https://bar)", MarkdownUtil.getMarkdownLink("Foo", "https://bar"));
}
@Test
public void testLineStartsWithCheckbox() {
final var lines = new HashMap<String, Boolean>();
lines.put(" - [ ] a", true);
lines.put(" - [x] a", true);
lines.put(" - [X] a", true);
lines.put(" * [ ] a", true);
lines.put(" * [x] a", true);
lines.put(" * [X] a", true);
lines.put(" + [ ] a", true);
lines.put(" + [x] a", true);
lines.put(" + [X] a", true);
lines.put("- [ ] a", true);
lines.put("- [x] a", true);
lines.put("- [X] a", true);
lines.put("* [ ] a", true);
lines.put("* [x] a", true);
lines.put("* [X] a", true);
lines.put("+ [ ] a", true);
lines.put("+ [x] a", true);
lines.put("+ [X] a", true);
lines.put(" - [ ] ", true);
lines.put(" - [x] ", true);
lines.put(" - [X] ", true);
lines.put(" * [ ] ", true);
lines.put(" * [x] ", true);
lines.put(" * [X] ", true);
lines.put(" + [ ] ", true);
lines.put(" + [x] ", true);
lines.put(" + [X] ", true);
lines.put(" - [ ]", true);
lines.put(" - [x]", true);
lines.put(" - [X]", true);
lines.put(" * [ ]", true);
lines.put(" * [x]", true);
lines.put(" * [X]", true);
lines.put(" + [ ]", true);
lines.put(" + [x]", true);
lines.put(" + [X]", true);
lines.put("- [ ] ", true);
lines.put("- [x] ", true);
lines.put("- [X] ", true);
lines.put("* [ ] ", true);
lines.put("* [x] ", true);
lines.put("* [X] ", true);
lines.put("+ [ ] ", true);
lines.put("+ [x] ", true);
lines.put("+ [X] ", true);
lines.put("- [ ]", true);
lines.put("- [x]", true);
lines.put("- [X]", true);
lines.put("* [ ]", true);
lines.put("* [x]", true);
lines.put("* [X]", true);
lines.put("+ [ ]", true);
lines.put("+ [x]", true);
lines.put("+ [X]", true);
lines.put("-[ ] ", false);
lines.put("-[x] ", false);
lines.put("-[X] ", false);
lines.put("*[ ] ", false);
lines.put("*[x] ", false);
lines.put("*[X] ", false);
lines.put("+[ ] ", false);
lines.put("+[x] ", false);
lines.put("+[X] ", false);
lines.put("-[ ]", false);
lines.put("-[x]", false);
lines.put("-[X]", false);
lines.put("*[ ]", false);
lines.put("*[x]", false);
lines.put("*[X]", false);
lines.put("+[ ]", false);
lines.put("+[x]", false);
lines.put("+[X]", false);
lines.put("- [] ", false);
lines.put("* [] ", false);
lines.put("+ [] ", false);
lines.put("- []", false);
lines.put("* []", false);
lines.put("+ []", false);
lines.put("-[] ", false);
lines.put("*[] ", false);
lines.put("+[] ", false);
lines.put("-[]", false);
lines.put("*[]", false);
lines.put("+[]", false);
lines.forEach((key, value) -> assertEquals(value, (Boolean) MarkdownUtil.lineStartsWithCheckbox(key)));
}
@Test
public void testTogglePunctuation() {
Editable builder;
// Add italic
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(12, MarkdownUtil.togglePunctuation(builder, 6, 11, "*"));
assertEquals("Lorem *ipsum* dolor sit amet.", builder.toString());
// Remove italic
builder = new SpannableStringBuilder("Lorem *ipsum* dolor sit amet.");
assertEquals(11, MarkdownUtil.togglePunctuation(builder, 7, 12, "*"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Add bold
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(13, MarkdownUtil.togglePunctuation(builder, 6, 11, "**"));
assertEquals("Lorem **ipsum** dolor sit amet.", builder.toString());
// Remove bold
builder = new SpannableStringBuilder("Lorem **ipsum** dolor sit amet.");
assertEquals(11, MarkdownUtil.togglePunctuation(builder, 8, 13, "**"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Add strike
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(13, MarkdownUtil.togglePunctuation(builder, 6, 11, "~~"));
assertEquals("Lorem ~~ipsum~~ dolor sit amet.", builder.toString());
// Remove strike
builder = new SpannableStringBuilder("Lorem ~~ipsum~~ dolor sit amet.");
assertEquals(11, MarkdownUtil.togglePunctuation(builder, 8, 13, "~~"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Add italic at first position
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(6, MarkdownUtil.togglePunctuation(builder, 0, 5, "*"));
assertEquals("*Lorem* ipsum dolor sit amet.", builder.toString());
// Remove italic from first position
builder = new SpannableStringBuilder("*Lorem* ipsum dolor sit amet.");
assertEquals(5, MarkdownUtil.togglePunctuation(builder, 1, 6, "*"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Add italic at last position
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(28, MarkdownUtil.togglePunctuation(builder, 22, 27, "*"));
assertEquals("Lorem ipsum dolor sit *amet.*", builder.toString());
// Remove italic from last position
builder = new SpannableStringBuilder("Lorem ipsum dolor sit *amet.*");
assertEquals(27, MarkdownUtil.togglePunctuation(builder, 23, 28, "*"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Text is not directly surrounded by punctuation but contains it
// Do nothing when the same punctuation is contained only one time
builder = new SpannableStringBuilder("Lorem *ipsum dolor sit amet.");
assertEquals(28, MarkdownUtil.togglePunctuation(builder, 0, 28, "*"));
assertEquals("Lorem *ipsum dolor sit amet.", builder.toString());
// Do nothing when the same punctuation is contained only one time
builder = new SpannableStringBuilder("Lorem **ipsum dolor sit amet.");
assertEquals(29, MarkdownUtil.togglePunctuation(builder, 0, 29, "**"));
assertEquals("Lorem **ipsum dolor sit amet.", builder.toString());
// Remove containing punctuation
builder = new SpannableStringBuilder("Lorem *ipsum* dolor sit amet.");
assertEquals(11, MarkdownUtil.togglePunctuation(builder, 6, 13, "*"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Remove containing punctuation
builder = new SpannableStringBuilder("Lorem *ipsum* dolor sit amet.");
assertEquals(27, MarkdownUtil.togglePunctuation(builder, 0, 29, "*"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Remove multiple containing punctuations
builder = new SpannableStringBuilder("Lorem *ipsum* dolor *sit* amet.");
assertEquals(27, MarkdownUtil.togglePunctuation(builder, 0, 31, "*"));
assertEquals("Lorem ipsum dolor sit amet.", builder.toString());
// Toggle italic on bold text
builder = new SpannableStringBuilder("Lorem **ipsum** dolor sit amet.");
assertEquals(14, MarkdownUtil.togglePunctuation(builder, 8, 13, "*"));
assertEquals("Lorem ***ipsum*** dolor sit amet.", builder.toString());
// Toggle italic on bold text
builder = new SpannableStringBuilder("Lorem **ipsum** dolor sit amet.");
assertEquals(16, MarkdownUtil.togglePunctuation(builder, 6, 15, "*"));
assertEquals("Lorem ***ipsum*** dolor sit amet.", builder.toString());
// Toggle bold on italic text
builder = new SpannableStringBuilder("Lorem *ipsum* dolor sit amet.");
assertEquals(14, MarkdownUtil.togglePunctuation(builder, 7, 12, "**"));
assertEquals("Lorem ***ipsum*** dolor sit amet.", builder.toString());
// Toggle bold to italic
builder = new SpannableStringBuilder("Lorem **ipsum** dolor sit amet.");
assertEquals(32, MarkdownUtil.togglePunctuation(builder, 0, 31, "*"));
assertEquals("*Lorem **ipsum** dolor sit amet.*", builder.toString());
// Toggle italic and bold to bold
builder = new SpannableStringBuilder("Lorem ***ipsum*** dolor sit amet.");
assertEquals(13, MarkdownUtil.togglePunctuation(builder, 0, 14, "*"));
assertEquals("Lorem **ipsum** dolor sit amet.", builder.toString());
// toggle italic around multiple existing bolds
builder = new SpannableStringBuilder("Lorem **ipsum** dolor **sit** amet.");
assertEquals(35, MarkdownUtil.togglePunctuation(builder, 0, 34, "*"));
assertEquals("*Lorem **ipsum** dolor **sit** amet*.", builder.toString());
// Toggle italic and bold to italic
builder = new SpannableStringBuilder("Lorem ***ipsum*** dolor sit amet.");
assertEquals(12, MarkdownUtil.togglePunctuation(builder, 9, 14, "**"));
assertEquals("Lorem *ipsum* dolor sit amet.", builder.toString());
// Toggle multiple italic and bold to bold
builder = new SpannableStringBuilder("Lorem ***ipsum*** dolor ***sit*** amet.");
assertEquals(34, MarkdownUtil.togglePunctuation(builder, 0, 38, "*"));
assertEquals("Lorem **ipsum** dolor **sit** amet.", builder.toString());
// Toggle multiple italic and bold to italic
builder = new SpannableStringBuilder("Lorem ***ipsum*** dolor ***sit*** amet.");
assertEquals(30, MarkdownUtil.togglePunctuation(builder, 0, 38, "**"));
assertEquals("Lorem *ipsum* dolor *sit* amet.", builder.toString());
// Toggle italic on an empty text
builder = new SpannableStringBuilder("");
assertEquals(1, MarkdownUtil.togglePunctuation(builder, 0, 0, "*"));
assertEquals("**", builder.toString());
// Toggle italic on a blank selection
builder = new SpannableStringBuilder(" ");
assertEquals(2, MarkdownUtil.togglePunctuation(builder, 0, 1, "*"));
assertEquals("* *", builder.toString());
// Toggle italic on a partial blank selection
builder = new SpannableStringBuilder(" ");
assertEquals(3, MarkdownUtil.togglePunctuation(builder, 1, 2, "*"));
assertEquals(" * * ", builder.toString());
// Toggle bold on an empty text
builder = new SpannableStringBuilder("");
assertEquals(2, MarkdownUtil.togglePunctuation(builder, 0, 0, "**"));
assertEquals("****", builder.toString());
// Toggle bold on a blank selection
builder = new SpannableStringBuilder(" ");
assertEquals(3, MarkdownUtil.togglePunctuation(builder, 0, 1, "**"));
assertEquals("** **", builder.toString());
// Toggle bold on a partial blank selection
builder = new SpannableStringBuilder(" ");
assertEquals(4, MarkdownUtil.togglePunctuation(builder, 1, 2, "**"));
assertEquals(" ** ** ", builder.toString());
// Toggle italic right after bold
builder = new SpannableStringBuilder("**Bold**Italic");
assertEquals(15, MarkdownUtil.togglePunctuation(builder, 8, 14, "*"));
assertEquals("**Bold***Italic*", builder.toString());
// Toggle italic for last of many bolds in one line
builder = new SpannableStringBuilder("Lorem **Ipsum** **Dolor**");
assertEquals(24, MarkdownUtil.togglePunctuation(builder, 18, 23, "*"));
assertEquals("Lorem **Ipsum** ***Dolor***", builder.toString());
builder = new SpannableStringBuilder("Lorem **Ipsum** **Dolor**");
assertEquals(14, MarkdownUtil.togglePunctuation(builder, 8, 13, "*"));
assertEquals("Lorem ***Ipsum*** **Dolor**", builder.toString());
builder = new SpannableStringBuilder("Lorem **Ipsum** **Dolor**");
assertEquals(16, MarkdownUtil.togglePunctuation(builder, 6, 15, "*"));
assertEquals("Lorem ***Ipsum*** **Dolor**", builder.toString());
// Toggle italic for last bold + italic in a row of multiple marked elements
builder = new SpannableStringBuilder("Lorem **Ipsum** ***Dolor***");
assertEquals(23, MarkdownUtil.togglePunctuation(builder, 19, 24, "*"));
assertEquals("Lorem **Ipsum** **Dolor**", builder.toString());
builder = new SpannableStringBuilder("Lorem ***Ipsum*** **Dolor**");
assertEquals(13, MarkdownUtil.togglePunctuation(builder, 9, 14, "*"));
assertEquals("Lorem **Ipsum** **Dolor**", builder.toString());
builder = new SpannableStringBuilder("Lorem ***Ipsum*** **Dolor**");
assertEquals(15, MarkdownUtil.togglePunctuation(builder, 6, 17, "*"));
assertEquals("Lorem **Ipsum** **Dolor**", builder.toString());
builder = new SpannableStringBuilder("Lorem ***Ipsum*** **Dolor**");
assertEquals(15, MarkdownUtil.togglePunctuation(builder, 7, 16, "*"));
assertEquals("Lorem **Ipsum** **Dolor**", builder.toString());
builder = new SpannableStringBuilder("Lorem ***Ipsum*** **Dolor**");
assertEquals(15, MarkdownUtil.togglePunctuation(builder, 7, 17, "*"));
assertEquals("Lorem **Ipsum** **Dolor**", builder.toString());
builder = new SpannableStringBuilder("Lorem ***Ipsum*** **Dolor**");
assertEquals(15, MarkdownUtil.togglePunctuation(builder, 8, 16, "*"));
assertEquals("Lorem **Ipsum** **Dolor**", builder.toString());
// Multiline
builder = new SpannableStringBuilder("Lorem ***Ipsum***\n **Dolor**");
assertEquals(29, MarkdownUtil.togglePunctuation(builder, 0, 28, "*"));
assertEquals("*Lorem ***Ipsum***\n **Dolor***", builder.toString());
builder = new SpannableStringBuilder("**Bold**\nItalic");
assertEquals(16, MarkdownUtil.togglePunctuation(builder, 9, 15, "*"));
assertEquals("**Bold**\n*Italic*", builder.toString());
builder = new SpannableStringBuilder("Bold\n*Italic*");
assertEquals(6, MarkdownUtil.togglePunctuation(builder, 0, 4, "**"));
assertEquals("**Bold**\n*Italic*", builder.toString());
builder = new SpannableStringBuilder("*Italic*\nBold");
assertEquals(15, MarkdownUtil.togglePunctuation(builder, 9, 13, "**"));
assertEquals("*Italic*\n**Bold**", builder.toString());
builder = new SpannableStringBuilder("Italic\n**Bold**");
assertEquals(7, MarkdownUtil.togglePunctuation(builder, 0, 6, "*"));
assertEquals("*Italic*\n**Bold**", builder.toString());
}
@Test
public void testInsertLink() {
Editable builder;
// Add link without clipboardUrl to normal text
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(14, MarkdownUtil.insertLink(builder, 6, 11, null));
assertEquals("Lorem [ipsum]() dolor sit amet.", builder.toString());
// Add link without clipboardUrl to url
builder = new SpannableStringBuilder("Lorem https://example.com dolor sit amet.");
assertEquals(7, MarkdownUtil.insertLink(builder, 6, 25, null));
assertEquals("Lorem [](https://example.com) dolor sit amet.", builder.toString());
// Add link without clipboardUrl to empty selection before space character
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(13, MarkdownUtil.insertLink(builder, 11, 11, null));
assertEquals("Lorem ipsum []() dolor sit amet.", builder.toString());
// Add link without clipboardUrl to empty selection after space character
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(13, MarkdownUtil.insertLink(builder, 12, 12, null));
assertEquals("Lorem ipsum []() dolor sit amet.", builder.toString());
// Add link without clipboardUrl to empty selection in word
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(20, MarkdownUtil.insertLink(builder, 14, 14, null));
assertEquals("Lorem ipsum [dolor]() sit amet.", builder.toString());
// Add link with clipboardUrl to normal text
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(33, MarkdownUtil.insertLink(builder, 6, 11, "https://example.com"));
assertEquals("Lorem [ipsum](https://example.com) dolor sit amet.", builder.toString());
// Add link with clipboardUrl to url
builder = new SpannableStringBuilder("Lorem https://example.com dolor sit amet.");
assertEquals(46, MarkdownUtil.insertLink(builder, 6, 25, "https://example.de"));
assertEquals("Lorem [https://example.com](https://example.de) dolor sit amet.", builder.toString());
// Add link with clipboardUrl to empty selection before space character
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(13, MarkdownUtil.insertLink(builder, 11, 11, "https://example.de"));
assertEquals("Lorem ipsum [](https://example.de) dolor sit amet.", builder.toString());
// Add link with clipboardUrl to empty selection after space character
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(13, MarkdownUtil.insertLink(builder, 12, 12, "https://example.de"));
assertEquals("Lorem ipsum [](https://example.de) dolor sit amet.", builder.toString());
// Add link with clipboardUrl to empty selection in word
builder = new SpannableStringBuilder("Lorem ipsum dolor sit amet.");
assertEquals(38, MarkdownUtil.insertLink(builder, 14, 14, "https://example.de"));
assertEquals("Lorem ipsum [dolor](https://example.de) sit amet.", builder.toString());
// Add link without clipboardUrl to empty selection on empty text
builder = new SpannableStringBuilder("");
assertEquals(1, MarkdownUtil.insertLink(builder, 0, 0, null));
assertEquals("[]()", builder.toString());
// Add link without clipboardUrl to empty selection on only space text
builder = new SpannableStringBuilder(" ");
assertEquals(1, MarkdownUtil.insertLink(builder, 0, 0, null));
assertEquals("[]() ", builder.toString());
// Add link without clipboardUrl to empty selection on only space text
builder = new SpannableStringBuilder(" ");
assertEquals(4, MarkdownUtil.insertLink(builder, 0, 1, null));
assertEquals("[ ]()", builder.toString());
// Add link without clipboardUrl to empty selection on only space text
builder = new SpannableStringBuilder(" ");
assertEquals(2, MarkdownUtil.insertLink(builder, 1, 1, null));
assertEquals(" []()", builder.toString());
// Add link without clipboardUrl to empty selection on only spaces
builder = new SpannableStringBuilder(" ");
assertEquals(2, MarkdownUtil.insertLink(builder, 1, 1, null));
assertEquals(" []() ", builder.toString());
// Add link without clipboardUrl to empty selection in word with trailing and leading spaces
builder = new SpannableStringBuilder(" Lorem ");
assertEquals(10, MarkdownUtil.insertLink(builder, 5, 5, null));
assertEquals(" [Lorem]() ", builder.toString());
// Add link with clipboardUrl to empty selection on empty text
builder = new SpannableStringBuilder("");
assertEquals(1, MarkdownUtil.insertLink(builder, 0, 0, "https://www.example.com"));
assertEquals("[](https://www.example.com)", builder.toString());
// Add link with clipboardUrl to empty selection on only space text
builder = new SpannableStringBuilder(" ");
assertEquals(1, MarkdownUtil.insertLink(builder, 0, 0, "https://www.example.com"));
assertEquals("[](https://www.example.com) ", builder.toString());
// Add link with clipboardUrl to empty selection on only space text
builder = new SpannableStringBuilder(" ");
assertEquals(27, MarkdownUtil.insertLink(builder, 0, 1, "https://www.example.com"));
assertEquals("[ ](https://www.example.com)", builder.toString());
// Add link with clipboardUrl to empty selection on only space text
builder = new SpannableStringBuilder(" ");
assertEquals(2, MarkdownUtil.insertLink(builder, 1, 1, "https://www.example.com"));
assertEquals(" [](https://www.example.com)", builder.toString());
// Add link with clipboardUrl to empty selection on one character
builder = new SpannableStringBuilder("a");
assertEquals(1, MarkdownUtil.insertLink(builder, 0, 0, "https://www.example.com"));
assertEquals("[](https://www.example.com)a", builder.toString());
// Add link with clipboardUrl to empty selection on one character
builder = new SpannableStringBuilder("a");
assertEquals(27, MarkdownUtil.insertLink(builder, 0, 1, "https://www.example.com"));
assertEquals("[a](https://www.example.com)", builder.toString());
// Add link with clipboardUrl to empty selection on one character
builder = new SpannableStringBuilder("a");
assertEquals(2, MarkdownUtil.insertLink(builder, 1, 1, "https://www.example.com"));
assertEquals("a[](https://www.example.com)", builder.toString());
// Add link with clipboardUrl to empty selection on only spaces
builder = new SpannableStringBuilder(" ");
assertEquals(2, MarkdownUtil.insertLink(builder, 1, 1, "https://www.example.com"));
assertEquals(" [](https://www.example.com) ", builder.toString());
// Add link with clipboardUrl to empty selection in word with trailing and leading spaces
builder = new SpannableStringBuilder(" Lorem ");
assertEquals(33, MarkdownUtil.insertLink(builder, 5, 5, "https://www.example.com"));
assertEquals(" [Lorem](https://www.example.com) ", builder.toString());
// Add link with clipboardUrl to selection in word with trailing and leading spaces
builder = new SpannableStringBuilder(" Lorem ");
assertEquals(33, MarkdownUtil.insertLink(builder, 2, 7, "https://www.example.com"));
assertEquals(" [Lorem](https://www.example.com) ", builder.toString());
}
@Test
@SuppressWarnings("ConstantConditions")
public void testSelectionIsInLink() {
try {
final var method = MarkdownUtil.class.getDeclaredMethod("selectionIsInLink", CharSequence.class, int.class, int.class);
method.setAccessible(true);
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 7, 12));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 6, 34));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 14, 33));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 12, 14));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 0, 7));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 33, 34));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 6, 28));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 7, 28));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 8, 28));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 9, 28));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 6, 29));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 7, 29));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 8, 29));
assertTrue((Boolean) method.invoke(null, "Lorem [](https://example.com) dolor sit amet.", 9, 29));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 6, 12));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 6, 13));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 6, 14));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 6, 15));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 7, 12));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 7, 13));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 7, 14));
assertTrue((Boolean) method.invoke(null, "Lorem [ipsum]() dolor sit amet.", 7, 15));
assertFalse((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 0, 6));
assertFalse((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 34, 50));
assertFalse((Boolean) method.invoke(null, "Lorem [ipsum](https://example.com) dolor sit amet.", 41, 44));
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
@SuppressWarnings("OptionalGetWithoutIsPresent")
@Test
public void testGetListItemIfIsEmpty() {
assertEquals("- ", MarkdownUtil.getListItemIfIsEmpty("- ").get());
assertEquals("+ ", MarkdownUtil.getListItemIfIsEmpty("+ ").get());
assertEquals("* ", MarkdownUtil.getListItemIfIsEmpty("* ").get());
assertEquals("1. ", MarkdownUtil.getListItemIfIsEmpty("1. ").get());
assertEquals(" - ", MarkdownUtil.getListItemIfIsEmpty(" - ").get());
assertEquals(" + ", MarkdownUtil.getListItemIfIsEmpty(" + ").get());
assertEquals(" * ", MarkdownUtil.getListItemIfIsEmpty(" * ").get());
assertEquals(" 1. ", MarkdownUtil.getListItemIfIsEmpty(" 1. ").get());
assertEquals(" - ", MarkdownUtil.getListItemIfIsEmpty(" - ").get());
assertEquals(" + ", MarkdownUtil.getListItemIfIsEmpty(" + ").get());
assertEquals(" * ", MarkdownUtil.getListItemIfIsEmpty(" * ").get());
assertEquals(" 1. ", MarkdownUtil.getListItemIfIsEmpty(" 1. ").get());
assertFalse(MarkdownUtil.getListItemIfIsEmpty("- Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty("+ Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty("* Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty("1. s").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty("1. ").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" - Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" + Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" * Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" 1. s").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" 1. ").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" - Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" + Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" * Test").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" 1. s").isPresent());
assertFalse(MarkdownUtil.getListItemIfIsEmpty(" 1. ").isPresent());
}
@SuppressWarnings("OptionalGetWithoutIsPresent")
@Test
public void testLineStartsWithOrderedList() {
assertEquals(1, MarkdownUtil.getOrderedListNumber("1. Test").get().intValue());
assertEquals(2, MarkdownUtil.getOrderedListNumber("2. Test").get().intValue());
assertEquals(3, MarkdownUtil.getOrderedListNumber("3. Test").get().intValue());
assertEquals(10, MarkdownUtil.getOrderedListNumber("10. Test").get().intValue());
assertEquals(11, MarkdownUtil.getOrderedListNumber("11. Test").get().intValue());
assertEquals(12, MarkdownUtil.getOrderedListNumber("12. Test").get().intValue());
assertEquals(1, MarkdownUtil.getOrderedListNumber("1. 1").get().intValue());
assertEquals(1, MarkdownUtil.getOrderedListNumber("1. Test 1").get().intValue());
assertFalse(MarkdownUtil.getOrderedListNumber("").isPresent());
assertFalse(MarkdownUtil.getOrderedListNumber("1.").isPresent());
assertFalse(MarkdownUtil.getOrderedListNumber("1. ").isPresent());
assertFalse(MarkdownUtil.getOrderedListNumber("11. ").isPresent());
assertFalse(MarkdownUtil.getOrderedListNumber("-1. Test").isPresent());
assertFalse(MarkdownUtil.getOrderedListNumber(" 1. Test").isPresent());
}
@Test
public void testSetCheckboxStatus() {
for (final var listType : EListType.values()) {
final String origin_1 = listType.checkboxUnchecked + " Item";
final String expected_1 = listType.checkboxChecked + " Item";
assertEquals(expected_1, MarkdownUtil.setCheckboxStatus(origin_1, 0, true));
final String origin_2 = listType.checkboxChecked + " Item";
final String expected_2 = listType.checkboxChecked + " Item";
assertEquals(expected_2, MarkdownUtil.setCheckboxStatus(origin_2, 0, true));
final String origin_3 = listType.checkboxChecked + " Item";
final String expected_3 = listType.checkboxChecked + " Item";
assertEquals(expected_3, MarkdownUtil.setCheckboxStatus(origin_3, -1, true));
final String origin_4 = listType.checkboxChecked + " Item";
final String expected_4 = listType.checkboxChecked + " Item";
assertEquals(expected_4, MarkdownUtil.setCheckboxStatus(origin_4, 3, true));
final String origin_5 = "" +
listType.checkboxChecked + " Item\n" +
listType.checkboxChecked + " Item";
final String expected_5 = "" +
listType.checkboxChecked + " Item\n" +
listType.checkboxUnchecked + " Item";
assertEquals(expected_5, MarkdownUtil.setCheckboxStatus(origin_5, 1, false));
// Checkboxes in fenced code block aren't rendered by Markwon and therefore don't count to the checkbox index
final String origin_6 = "" +
listType.checkboxChecked + " Item\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"```\n" +
listType.checkboxUnchecked + " Item";
final String expected_6 = "" +
listType.checkboxChecked + " Item\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"```\n" +
listType.checkboxChecked + " Item";
assertEquals(expected_6, MarkdownUtil.setCheckboxStatus(origin_6, 1, true));
// Checkbox in partial nested fenced code block does not count as rendered checkbox
final String origin_7 = "" +
listType.checkboxChecked + " Item\n" +
"````\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"````\n" +
listType.checkboxUnchecked + " Item";
final String expected_7 = "" +
listType.checkboxChecked + " Item\n" +
"````\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"````\n" +
listType.checkboxChecked + " Item";
assertEquals(expected_7, MarkdownUtil.setCheckboxStatus(origin_7, 1, true));
// Checkbox in complete nested fenced code block does not count as rendered checkbox
final String origin_8 = "" +
listType.checkboxChecked + " Item\n" +
"````\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"```\n" +
"````\n" +
listType.checkboxUnchecked + " Item";
final String expected_8 = "" +
listType.checkboxChecked + " Item\n" +
"````\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"```\n" +
"````\n" +
listType.checkboxChecked + " Item";
assertEquals(expected_8, MarkdownUtil.setCheckboxStatus(origin_8, 1, true));
// If checkbox has no content, it doesn't get rendered by Markwon and therefore can not be checked
final String origin_9 = "" +
listType.checkboxChecked + " Item\n" +
"````\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"```\n" +
"````\n" +
listType.checkboxUnchecked + " \n" +
listType.checkboxUnchecked + " Item";
final String expected_9 = "" +
listType.checkboxChecked + " Item\n" +
"````\n" +
"```\n" +
listType.checkboxUnchecked + " Item\n" +
"```\n" +
"````\n" +
listType.checkboxUnchecked + " \n" +
listType.checkboxChecked + " Item";
assertEquals(expected_9, MarkdownUtil.setCheckboxStatus(origin_9, 1, true));
final String origin_10 = "" +
listType.checkboxChecked + " Item\n" +
listType.checkboxCheckedUpperCase + " Item";
final String expected_10 = "" +
listType.checkboxChecked + " Item\n" +
listType.checkboxUnchecked + " Item";
assertEquals(expected_10, MarkdownUtil.setCheckboxStatus(origin_10, 1, false));
}
}
@Test
public void testRemoveSpans() {
try {
final var removeSpans = MarkdownUtil.class.getDeclaredMethod("removeSpans", Spannable.class, Class.class);
removeSpans.setAccessible(true);
final var editable_1 = new SpannableStringBuilder("Lorem Ipsum dolor sit amet");
editable_1.setSpan(new SearchSpan(Color.RED, Color.GRAY, false, false), 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable_1.setSpan(new ForegroundColorSpan(Color.BLUE), 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable_1.setSpan(new SearchSpan(Color.BLUE, Color.GREEN, true, false), 12, 17, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
removeSpans.invoke(null, editable_1, SearchSpan.class);
assertEquals(0, editable_1.getSpans(0, editable_1.length(), SearchSpan.class).length);
assertEquals(1, editable_1.getSpans(0, editable_1.length(), ForegroundColorSpan.class).length);
final var editable_2 = new SpannableStringBuilder("Lorem Ipsum dolor sit amet");
editable_2.setSpan(new SearchSpan(Color.GRAY, Color.RED, false, true), 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable_2.setSpan(new ForegroundColorSpan(Color.BLUE), 2, 7, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable_2.setSpan(new SearchSpan(Color.BLUE, Color.GREEN, true, false), 3, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
removeSpans.invoke(null, editable_2, SearchSpan.class);
assertEquals(0, editable_2.getSpans(0, editable_2.length(), SearchSpan.class).length);
assertEquals(1, editable_2.getSpans(0, editable_2.length(), ForegroundColorSpan.class).length);
assertEquals(2, editable_2.getSpanStart(editable_2.getSpans(0, editable_2.length(), ForegroundColorSpan.class)[0]));
assertEquals(7, editable_2.getSpanEnd(editable_2.getSpans(0, editable_2.length(), ForegroundColorSpan.class)[0]));
final var editable_3 = new SpannableStringBuilder("Lorem Ipsum dolor sit amet");
editable_3.setSpan(new ForegroundColorSpan(Color.BLUE), 2, 7, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
removeSpans.invoke(null, editable_3, SearchSpan.class);
assertEquals(0, editable_3.getSpans(0, editable_3.length(), SearchSpan.class).length);
assertEquals(1, editable_3.getSpans(0, editable_3.length(), ForegroundColorSpan.class).length);
assertEquals(2, editable_3.getSpanStart(editable_3.getSpans(0, editable_3.length(), ForegroundColorSpan.class)[0]));
assertEquals(7, editable_3.getSpanEnd(editable_3.getSpans(0, editable_3.length(), ForegroundColorSpan.class)[0]));
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
@Test
public void testRemoveMarkdown() {
assertEquals("Test", MarkdownUtil.removeMarkdown("Test"));
assertEquals("Foo\nBar", MarkdownUtil.removeMarkdown("Foo\nBar"));
assertEquals("Foo\nBar", MarkdownUtil.removeMarkdown("Foo\n Bar"));
assertEquals("Foo\nBar", MarkdownUtil.removeMarkdown("Foo \nBar"));
assertEquals("Foo-Bar", MarkdownUtil.removeMarkdown("Foo-Bar"));
assertEquals("Foo*Bar", MarkdownUtil.removeMarkdown("Foo*Bar"));
assertEquals("Foo/Bar", MarkdownUtil.removeMarkdown("Foo/Bar"));
assertEquals("FooTestBar", MarkdownUtil.removeMarkdown("Foo*Test*Bar"));
assertEquals("FooTestBar", MarkdownUtil.removeMarkdown("Foo**Test**Bar"));
assertEquals("FooTestBar", MarkdownUtil.removeMarkdown("Foo***Test***Bar"));
assertEquals("Foo*Test**Bar", MarkdownUtil.removeMarkdown("Foo*Test**Bar"));
assertEquals("Foo*TestBar", MarkdownUtil.removeMarkdown("Foo***Test**Bar"));
assertEquals("Foo_Test_Bar", MarkdownUtil.removeMarkdown("Foo_Test_Bar"));
assertEquals("Foo__Test__Bar", MarkdownUtil.removeMarkdown("Foo__Test__Bar"));
assertEquals("Foo___Test___Bar", MarkdownUtil.removeMarkdown("Foo___Test___Bar"));
assertEquals("Foo\nHeader\nBar", MarkdownUtil.removeMarkdown("Foo\n# Header\nBar"));
assertEquals("Foo\nHeader\nBar", MarkdownUtil.removeMarkdown("Foo\n### Header\nBar"));
assertEquals("Foo\nHeader\nBar", MarkdownUtil.removeMarkdown("Foo\n# Header #\nBar"));
assertEquals("Foo\nHeader\nBar", MarkdownUtil.removeMarkdown("Foo\n## Header ####\nBar"));
assertEquals("Foo\nNo Header #\nBar", MarkdownUtil.removeMarkdown("Foo\nNo Header #\nBar"));
assertEquals("Foo\nHeader\nBar", MarkdownUtil.removeMarkdown("Foo\nHeader\n=\nBar"));
assertEquals("Foo\nHeader\nBar", MarkdownUtil.removeMarkdown("Foo\nHeader\n-----\nBar"));
assertEquals("Foo\nHeader\n--=--\nBar", MarkdownUtil.removeMarkdown("Foo\nHeader\n--=--\nBar"));
assertEquals("Foo\nAufzählung\nBar", MarkdownUtil.removeMarkdown("Foo\n* Aufzählung\nBar"));
assertEquals("Foo\nAufzählung\nBar", MarkdownUtil.removeMarkdown("Foo\n+ Aufzählung\nBar"));
assertEquals("Foo\nAufzählung\nBar", MarkdownUtil.removeMarkdown("Foo\n- Aufzählung\nBar"));
assertEquals("Foo\n- Aufzählung\nBar", MarkdownUtil.removeMarkdown("Foo\n - Aufzählung\nBar"));
assertEquals("Foo\nAufzählung *\nBar", MarkdownUtil.removeMarkdown("Foo\n* Aufzählung *\nBar"));
assertEquals("Title", MarkdownUtil.removeMarkdown("# Title"));
assertEquals("Aufzählung", MarkdownUtil.removeMarkdown("* Aufzählung"));
// assertEquals("Foo Link Bar", MarkdownUtil.removeMarkdown("Foo [Link](https://example.com) Bar"));
assertFalse(MarkdownUtil.removeMarkdown("- [ ] Test").contains("- [ ]"));
assertTrue(MarkdownUtil.removeMarkdown("- [ ] Test").endsWith("Test"));
assertEquals("", MarkdownUtil.removeMarkdown(null));
assertEquals("", MarkdownUtil.removeMarkdown(""));
// https://github.com/stefan-niedermann/nextcloud-notes/issues/1104
assertEquals("2021-03-24 - Example text", MarkdownUtil.removeMarkdown("2021-03-24 - Example text"));
}
}

View file

@ -1,210 +0,0 @@
package it.niedermann.android.markdown.markwon.plugins;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.URLSpan;
import android.util.Range;
import android.widget.TextView;
import androidx.test.core.app.ApplicationProvider;
import junit.framework.TestCase;
import org.commonmark.node.Node;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import java.lang.reflect.InvocationTargetException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.ext.tasklist.TaskListSpan;
import it.niedermann.android.markdown.markwon.span.InterceptedURLSpan;
import it.niedermann.android.markdown.markwon.span.ToggleTaskListSpan;
@RunWith(RobolectricTestRunner.class)
public class ToggleableTaskListPluginTest extends TestCase {
@Test
public void testAfterRender() throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException {
final var node = mock(Node.class);
final var visitor = mock(MarkwonVisitor.class);
final var markerSpanConstructor = ToggleableTaskListPlugin.ToggleMarkerSpan.class.getDeclaredConstructor(TaskListSpan.class);
markerSpanConstructor.setAccessible(true);
final var builder = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet");
builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 0, 6);
builder.setSpan(new URLSpan(""), 6, 11);
builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 11, 19);
builder.setSpan(new InterceptedURLSpan(Collections.emptyList(), ""), 19, 22);
builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 22, 27);
when(visitor.builder()).thenReturn(builder);
final var plugin = new ToggleableTaskListPlugin((i, b) -> {
// Do nothing...
});
plugin.afterRender(node, visitor);
// We ignore marker spans in this test. They will be removed in another step
final var spans = builder.getSpans(0, builder.length())
.stream()
.filter(span -> span.what.getClass() != ToggleableTaskListPlugin.ToggleMarkerSpan.class)
.sorted((o1, o2) -> o1.start - o2.start)
.collect(Collectors.toList());
assertEquals(5, spans.size());
assertEquals(ToggleTaskListSpan.class, spans.get(0).what.getClass());
assertEquals(0, spans.get(0).start);
assertEquals(6, spans.get(0).end);
assertEquals(URLSpan.class, spans.get(1).what.getClass());
assertEquals(6, spans.get(1).start);
assertEquals(11, spans.get(1).end);
assertEquals(ToggleTaskListSpan.class, spans.get(2).what.getClass());
assertEquals(11, spans.get(2).start);
assertEquals(19, spans.get(2).end);
assertEquals(InterceptedURLSpan.class, spans.get(3).what.getClass());
assertEquals(19, spans.get(3).start);
assertEquals(22, spans.get(3).end);
assertEquals(ToggleTaskListSpan.class, spans.get(4).what.getClass());
assertEquals(22, spans.get(4).start);
assertEquals(27, spans.get(4).end);
}
@Test
public void testAfterSetText() throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException {
final var markerSpanConstructor = ToggleableTaskListPlugin.ToggleMarkerSpan.class.getDeclaredConstructor(TaskListSpan.class);
markerSpanConstructor.setAccessible(true);
final var editable = new SpannableStringBuilder("Lorem Ipsum Dolor \nSit Amet");
editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable.setSpan(new URLSpan(""), 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 11, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable.setSpan(new InterceptedURLSpan(Collections.emptyList(), ""), 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 22, 27, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
final var textView = new TextView(ApplicationProvider.getApplicationContext());
textView.setText(editable);
assertEquals(3, ((Spanned) textView.getText()).getSpans(0, textView.getText().length(), ToggleableTaskListPlugin.ToggleMarkerSpan.class).length);
final var plugin = new ToggleableTaskListPlugin((i, b) -> {
// Do nothing...
});
plugin.afterSetText(textView);
assertEquals(0, ((Spanned) textView.getText()).getSpans(0, textView.getText().length(), ToggleableTaskListPlugin.ToggleMarkerSpan.class).length);
}
@Test
@SuppressWarnings({"unchecked", "ConstantConditions"})
public void testGetSortedSpans() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
final var method = ToggleableTaskListPlugin.class.getDeclaredMethod("getSortedSpans", SpannableBuilder.class, Class.class, int.class, int.class);
method.setAccessible(true);
final var firstClickableSpan = new URLSpan("");
final var secondClickableSpan = new InterceptedURLSpan(Collections.emptyList(), "");
final var unclickableSpan = new ForegroundColorSpan(android.R.color.white);
final var spannable = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet");
spannable.setSpan(firstClickableSpan, 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(secondClickableSpan, 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(unclickableSpan, 3, 20, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
List<SpannableBuilder.Span> clickableSpans;
clickableSpans = (List<SpannableBuilder.Span>) method.invoke(null, spannable, ClickableSpan.class, 0, 0);
assertEquals(0, clickableSpans.size());
clickableSpans = (List<SpannableBuilder.Span>) method.invoke(null, spannable, ClickableSpan.class, spannable.length() - 1, spannable.length() - 1);
assertEquals(0, clickableSpans.size());
clickableSpans = (List<SpannableBuilder.Span>) method.invoke(null, spannable, ClickableSpan.class, 0, 5);
assertEquals(0, clickableSpans.size());
clickableSpans = (List<SpannableBuilder.Span>) method.invoke(null, spannable, ClickableSpan.class, 0, spannable.length());
assertEquals(2, clickableSpans.size());
assertEquals(firstClickableSpan, clickableSpans.get(0).what);
assertEquals(secondClickableSpan, clickableSpans.get(1).what);
clickableSpans = (List<SpannableBuilder.Span>) method.invoke(null, spannable, ClickableSpan.class, 0, 17);
assertEquals(1, clickableSpans.size());
assertEquals(firstClickableSpan, clickableSpans.get(0).what);
clickableSpans = (List<SpannableBuilder.Span>) method.invoke(null, spannable, ClickableSpan.class, 12, 22);
assertEquals(1, clickableSpans.size());
assertEquals(secondClickableSpan, clickableSpans.get(0).what);
clickableSpans = (List<SpannableBuilder.Span>) method.invoke(null, spannable, ClickableSpan.class, 9, 20);
assertEquals(2, clickableSpans.size());
assertEquals(firstClickableSpan, clickableSpans.get(0).what);
assertEquals(secondClickableSpan, clickableSpans.get(1).what);
}
@Test
@SuppressWarnings({"unchecked", "ConstantConditions"})
public void testFindFreeRanges() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
final var method = ToggleableTaskListPlugin.class.getDeclaredMethod("findFreeRanges", SpannableBuilder.class, int.class, int.class);
method.setAccessible(true);
final var firstClickableSpan = new URLSpan("");
final var secondClickableSpan = new InterceptedURLSpan(Collections.emptyList(), "");
var spannable = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet");
spannable.setSpan(firstClickableSpan, 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(secondClickableSpan, 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
List<Range<Integer>> freeRanges;
freeRanges = (List<Range<Integer>>) method.invoke(null, spannable, 0, 0);
assertEquals(0, freeRanges.size());
freeRanges = (List<Range<Integer>>) method.invoke(null, spannable, spannable.length() - 1, spannable.length() - 1);
assertEquals(0, freeRanges.size());
freeRanges = (List<Range<Integer>>) method.invoke(null, spannable, 0, 6);
assertEquals(1, freeRanges.size());
assertEquals(0, (int) freeRanges.get(0).getLower());
assertEquals(6, (int) freeRanges.get(0).getUpper());
freeRanges = (List<Range<Integer>>) method.invoke(null, spannable, 0, 6);
assertEquals(1, freeRanges.size());
assertEquals(0, (int) freeRanges.get(0).getLower());
assertEquals(6, (int) freeRanges.get(0).getUpper());
freeRanges = (List<Range<Integer>>) method.invoke(null, spannable, 3, 15);
assertEquals(2, freeRanges.size());
assertEquals(3, (int) freeRanges.get(0).getLower());
assertEquals(6, (int) freeRanges.get(0).getUpper());
assertEquals(11, (int) freeRanges.get(1).getLower());
assertEquals(15, (int) freeRanges.get(1).getUpper());
freeRanges = (List<Range<Integer>>) method.invoke(null, spannable, 0, spannable.length());
assertEquals(3, freeRanges.size());
assertEquals(0, (int) freeRanges.get(0).getLower());
assertEquals(6, (int) freeRanges.get(0).getUpper());
assertEquals(11, (int) freeRanges.get(1).getLower());
assertEquals(19, (int) freeRanges.get(1).getUpper());
assertEquals(22, (int) freeRanges.get(2).getLower());
assertEquals(27, (int) freeRanges.get(2).getUpper());
// https://github.com/stefan-niedermann/nextcloud-notes/issues/1326
spannable = new SpannableBuilder("Test Crash\n\nJust text with link https://github.com");
spannable.setSpan(firstClickableSpan, 32, 50, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(secondClickableSpan, 32, 50, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
freeRanges = (List<Range<Integer>>) method.invoke(null, spannable, 12, 50);
assertEquals(1, freeRanges.size());
}
}

View file

@ -1,146 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.view.KeyEvent;
import android.widget.EditText;
import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
import androidx.test.core.app.ApplicationProvider;
import junit.framework.TestCase;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
import it.niedermann.android.markdown.model.EListType;
@RunWith(RobolectricTestRunner.class)
public class AutoContinuationTextWatcherTest extends TestCase {
@Rule
public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule();
private EditText editText;
@Before
public void reset() {
this.editText = new MarkwonMarkdownEditor(ApplicationProvider.getApplicationContext());
}
@Test
public void shouldContinueSimpleLists() {
for (EListType listType : EListType.values()) {
this.editText.setText(listType.listSymbolWithTrailingSpace + "Test");
pressEnter(6);
assertText(listType.listSymbolWithTrailingSpace + "Test\n" + listType.listSymbolWithTrailingSpace, 9);
}
for (EListType listType : EListType.values()) {
this.editText.setText(listType.checkboxUncheckedWithTrailingSpace + "Test");
pressEnter(10);
assertText(listType.checkboxUncheckedWithTrailingSpace + "Test\n" + listType.checkboxUncheckedWithTrailingSpace, 17);
}
for (EListType listType : EListType.values()) {
this.editText.setText(listType.checkboxChecked + " Test");
pressEnter(10);
assertText(listType.checkboxChecked + " Test\n" + listType.checkboxUncheckedWithTrailingSpace, 17);
}
this.editText.setText("1. Test");
pressEnter(7);
assertText("1. Test\n2. ", 11);
this.editText.setText("11. Test");
pressEnter(8);
assertText("11. Test\n12. ", 13);
}
@Test
public void shouldContinueListsWithMultipleItems() {
final CharSequence sample = "- [ ] Foo\n- [x] Bar\n- [ ] Baz\n\nQux";
this.editText.setText(sample);
pressEnter(0);
assertText("\n- [ ] Foo\n- [x] Bar\n- [ ] Baz\n\nQux", 1);
this.editText.setText(sample);
pressEnter(9);
assertText("- [ ] Foo\n- [ ] \n- [x] Bar\n- [ ] Baz\n\nQux", 16);
this.editText.setText(sample);
pressEnter(19);
assertText("- [ ] Foo\n- [x] Bar\n- [ ] \n- [ ] Baz\n\nQux", 26);
}
@Test
public void shouldSplitItemIfCursorIsNotOnEnd() {
this.editText.setText("- [ ] Foo\n- [x] Bar");
pressEnter(8);
assertText("- [ ] Fo\n- [ ] o\n- [x] Bar", 15);
}
@Test
public void shouldContinueNestedListsAtTheSameIndention() {
this.editText.setText("- [ ] Parent\n - [x] Child\n - [ ] Third");
pressEnter(26);
assertText("- [ ] Parent\n - [x] Child\n - [ ] \n - [ ] Third", 35);
this.editText.setText("- [ ] Parent\n - [x] Child\n - [ ] Third");
pressEnter(42);
assertText("- [ ] Parent\n - [x] Child\n - [ ] Third\n - [ ] ", 53);
this.editText.setText("1. Parent\n 1. Child");
pressEnter(20);
assertText("1. Parent\n 1. Child\n 2. ", 26);
}
@Test
public void shouldRemoveContinuedListItemWhenPressingEnterMultipleTimes() {
this.editText.setText("- Foo");
pressEnter(5);
assertText("- Foo\n- ", 8);
pressEnter(8);
assertText("- Foo\n\n", 7);
pressEnter(7);
assertText("- Foo\n\n\n", 8);
this.editText.setText("- Foo\n - Bar");
pressEnter(13);
assertText("- Foo\n - Bar\n - ", 18);
pressEnter(18);
assertText("- Foo\n - Bar\n\n", 15);
pressEnter(15);
assertText("- Foo\n - Bar\n\n\n", 16);
}
@Test
public void shouldNotContinueIfNoList() {
this.editText.setText("Foo");
pressEnter(3);
assertText("Foo\n", 4);
this.editText.setText(" Foo");
pressEnter(4);
assertText(" Foo\n", 5);
}
@Test
public void shouldNotContinueIfBlank() {
this.editText.setText("");
pressEnter(0);
assertText("\n", 1);
}
private void assertText(String expected, int cursorPosition) {
assertEquals(expected, this.editText.getText().toString());
assertEquals(cursorPosition, this.editText.getSelectionStart());
assertEquals(cursorPosition, this.editText.getSelectionEnd());
}
private void pressEnter(int atPosition) {
this.editText.setSelection(atPosition);
this.editText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER));
}
}

View file

@ -1,120 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.view.KeyEvent;
import android.widget.EditText;
import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
import androidx.test.core.app.ApplicationProvider;
import junit.framework.TestCase;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
@RunWith(RobolectricTestRunner.class)
public class LowerIndentionTextWatcherTest extends TestCase {
@Rule
public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule();
private EditText editText;
@Before
public void reset() {
this.editText = new MarkwonMarkdownEditor(ApplicationProvider.getApplicationContext());
}
@Test
public void shouldLowerIndentionByTwoWhenPressingBackspaceOnAnIndentedList() {
this.editText.setText(" - ");
pressBackspace(4);
assertText("- ", 2);
this.editText.setText("- [ ] Foo\n - [ ] ");
pressBackspace(18);
assertText("- [ ] Foo\n- [ ] ", 16);
}
@Test
public void shouldLowerIndentionByOneWhenPressingBackspaceOnAListWhichIsIndentedByOneSpace() {
this.editText.setText(" - ");
pressBackspace(3);
assertText("- ", 2);
}
@Test
public void shouldNotLowerIndentionByOneWhenCursorIsNotAtTheEnd() {
this.editText.setText(" - ");
pressBackspace(2);
assertText(" ", 1);
this.editText.setText(" - ");
pressBackspace(0);
assertText(" - ", 0);
this.editText.setText(" - ");
pressBackspace(3);
assertText(" ", 2);
this.editText.setText(" - ");
pressBackspace(2);
assertText(" - ", 1);
this.editText.setText("- Foo\n - ");
pressBackspace(9);
assertText("- Foo\n ", 8);
}
@Test
public void shouldNotLowerIndentionIfThereIsAnyContentAfterTheList() {
this.editText.setText(" - ");
pressBackspace(4);
assertText(" - ", 3);
this.editText.setText(" - ");
pressBackspace(5);
assertText(" - ", 4);
}
@Test
public void shouldNotLowerIndentionIfBackspaceWasPressedInTheNextLine() {
this.editText.setText(" - \nFoo");
pressBackspace(5);
assertText(" - Foo", 4);
}
@Test
public void shouldDeleteLastCharacterWhenPressingBackspace() {
this.editText.setText("");
pressBackspace(0);
assertText("", 0);
this.editText.setText("- [ ] ");
pressBackspace(6);
assertText("- [ ]", 5);
this.editText.setText("- Foo");
pressBackspace(5);
assertText("- Fo", 4);
this.editText.setText("- [ ] Foo");
pressBackspace(9);
assertText("- [ ] Fo", 8);
}
private void assertText(String expected, int cursorPosition) {
assertEquals(expected, this.editText.getText().toString());
assertEquals(cursorPosition, this.editText.getSelectionStart());
assertEquals(cursorPosition, this.editText.getSelectionEnd());
}
private void pressBackspace(int atPosition) {
this.editText.setSelection(atPosition);
this.editText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
}
}

View file

@ -1,167 +0,0 @@
package it.niedermann.android.markdown.markwon.textwatcher;
import android.view.KeyEvent;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
import androidx.test.core.app.ApplicationProvider;
import junit.framework.TestCase;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import it.niedermann.android.markdown.markwon.MarkwonMarkdownEditor;
import it.niedermann.android.markdown.model.EListType;
import static android.view.KeyEvent.KEYCODE_1;
import static android.view.KeyEvent.KEYCODE_A;
import static android.view.KeyEvent.KEYCODE_B;
import static android.view.KeyEvent.KEYCODE_C;
import static android.view.KeyEvent.KEYCODE_CTRL_LEFT;
import static android.view.KeyEvent.KEYCODE_F;
import static android.view.KeyEvent.KEYCODE_LEFT_BRACKET;
import static android.view.KeyEvent.KEYCODE_MINUS;
import static android.view.KeyEvent.KEYCODE_O;
import static android.view.KeyEvent.KEYCODE_PERIOD;
import static android.view.KeyEvent.KEYCODE_Q;
import static android.view.KeyEvent.KEYCODE_R;
import static android.view.KeyEvent.KEYCODE_RIGHT_BRACKET;
import static android.view.KeyEvent.KEYCODE_SPACE;
import static android.view.KeyEvent.KEYCODE_STAR;
import static android.view.KeyEvent.KEYCODE_U;
import static android.view.KeyEvent.KEYCODE_X;
@RunWith(RobolectricTestRunner.class)
public class TextWatcherIntegrationTest extends TestCase {
@Rule
public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule();
private EditText editText;
@Before
public void reset() {
this.editText = new MarkwonMarkdownEditor(ApplicationProvider.getApplicationContext());
}
@Test
public void shouldSuccessfullyHandleMultipleTextEdits() {
this.editText.setText("");
assertEquals("");
sendKeys(KEYCODE_F, KEYCODE_O, KEYCODE_O);
assertEquals("foo");
pressEnter();
assertEquals("foo\n");
sendCheckboxKeys();
assertEquals("foo\n- [ ] ");
pressEnter();
assertEquals("foo\n\n");
sendCheckboxKeys();
sendKeys(KEYCODE_B, KEYCODE_A, KEYCODE_R);
assertEquals("foo\n\n- [ ] bar");
pressEnter();
assertEquals("foo\n\n- [ ] bar\n- [ ] ");
pressEnter();
assertEquals("foo\n\n- [ ] bar\n\n");
pressBackspace();
assertEquals("foo\n\n- [ ] bar\n");
sendKeys(KEYCODE_SPACE, KEYCODE_SPACE);
sendCheckboxKeys();
sendKeys(KEYCODE_B, KEYCODE_A, KEYCODE_R);
assertEquals("foo\n\n- [ ] bar\n - [ ] bar");
pressEnter();
assertEquals("foo\n\n- [ ] bar\n - [ ] bar\n - [ ] ");
pressBackspace();
assertEquals("foo\n\n- [ ] bar\n - [ ] bar\n- [ ] ");
pressBackspace(6, 33);
assertEquals("foo\n\n- [ ] bar\n - [ ] bar\n");
sendKeys(KEYCODE_SPACE, KEYCODE_SPACE, KEYCODE_1, KEYCODE_PERIOD, KEYCODE_SPACE, KEYCODE_Q, KEYCODE_U, KEYCODE_X);
assertEquals("foo\n\n- [ ] bar\n - [ ] bar\n 1. qux");
pressEnter();
assertEquals("foo\n\n- [ ] bar\n - [ ] bar\n 1. qux\n 2. ");
pressEnter(14);
pressBackspace(6, 21);
assertEquals("foo\n\n- [ ] bar\n\n - [ ] bar\n 1. qux\n 2. ");
sendKeys(KEYCODE_SPACE, KEYCODE_SPACE, KEYCODE_STAR);
pressBackspace();
sendKeys(KEYCODE_STAR, KEYCODE_SPACE, KEYCODE_A, KEYCODE_B, KEYCODE_C);
assertEquals("foo\n\n- [ ] bar\n * abc\n - [ ] bar\n 1. qux\n 2. ");
}
// Convenience methods
private void sendCheckboxKeys() {
sendKeys(KEYCODE_MINUS, KEYCODE_SPACE, KEYCODE_CTRL_LEFT, KEYCODE_LEFT_BRACKET, KEYCODE_SPACE, KEYCODE_RIGHT_BRACKET, KEYCODE_SPACE);
}
private void assertEquals(@NonNull CharSequence expected) {
assertEquals(expected.toString(), editText.getText().toString());
}
private void pressEnter(@SuppressWarnings("SameParameterValue") int atPosition) {
this.editText.setSelection(atPosition);
pressEnter();
}
private void pressEnter() {
this.editText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER));
}
private void pressBackspace(@SuppressWarnings("SameParameterValue") int times, int startPosition) {
if (times > startPosition) {
throw new IllegalArgumentException("startPosition must be bigger or equal to times");
}
while (times > 0) {
times--;
pressBackspace(startPosition--);
}
}
private void pressBackspace(int atPosition) {
this.editText.setSelection(atPosition);
pressBackspace();
}
private void pressBackspace() {
this.editText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
}
private void sendKeys(int... keyCodes) {
int release = -1;
for (int k : keyCodes) {
if (release != -1) {
this.editText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, release));
release = -1;
}
switch (k) {
case KeyEvent.KEYCODE_CTRL_LEFT:
case KeyEvent.KEYCODE_CTRL_RIGHT: {
release = k;
}
}
this.editText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, k));
}
}
}

View file

@ -1 +0,0 @@
sdk=22, 30

View file

@ -1,2 +1 @@
include ':app'
include ':markdown'