diff --git a/CHANGELOG.md b/CHANGELOG.md index 96183500..47d8defa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,16 @@ * `ext-tables` - `TableAwareMovementMethod` a special movement method to handle clicks inside tables ([#289]) #### Changed +* `ext-tasklist` - changed implementation to be in line with GFM (Github flavored markdown), + task list item is a regular list item (BulletList and OrderedList can contain it). + Internal implementation changed from block parsing to node post processing ([#291]) * `image-glide` - update to `4.11.0` version * `inline-parser` - revert parsing index when `InlineProcessor` returns `null` as result * `image-coil` - update `Coil` to `0.12.0` ([Coil changelog](https://coil-kt.github.io/coil/changelog/)) ([#284])
Thanks [@magnusvs] [#284]: https://github.com/noties/Markwon/pull/284 [#289]: https://github.com/noties/Markwon/issues/289 +[#291]: https://github.com/noties/Markwon/issues/291 [@magnusvs]: https://github.com/magnusvs diff --git a/app-sample/samples.json b/app-sample/samples.json index e93b0bae..2ac8a06a 100644 --- a/app-sample/samples.json +++ b/app-sample/samples.json @@ -1,4 +1,16 @@ [ + { + "javaClassName": "io.noties.markwon.app.samples.tasklist.ListTaskListSample", + "id": "20200902174132", + "title": "Task list items with other lists", + "description": "Mix of task list items with other lists (bullet and ordered)", + "artifacts": [ + "EXT_TASKLIST" + ], + "tags": [ + "lists" + ] + }, { "javaClassName": "io.noties.markwon.app.samples.DeeplinksSample", "id": "20200826122247", diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/ListTaskListSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/ListTaskListSample.java new file mode 100644 index 00000000..0bd3cecd --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/ListTaskListSample.java @@ -0,0 +1,38 @@ +package io.noties.markwon.app.samples.tasklist; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tasklist.TaskListPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200902174132", + title = "Task list items with other lists", + description = "Mix of task list items with other lists (bullet and ordered)", + artifacts = MarkwonArtifact.EXT_TASKLIST, + tags = Tags.lists +) +public class ListTaskListSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "- [ ] Task **1**\n" + + "- [ ] _Task_ 2\n" + + "- [ ] Task 3\n" + + " - Sub Task 3.1\n" + + " - Sub Task 3.2\n" + + " * [X] Sub Task 4.1\n" + + " * [X] Sub Task 4.2\n" + + "- [ ] Task 4\n" + + " - [ ] Sub Task 3.1\n" + + " - [ ] Sub Task 3.2"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(TaskListPlugin.create(context)) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java b/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java index 474021e7..5782354b 100644 --- a/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java +++ b/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java @@ -1,9 +1,9 @@ package io.noties.markwon.utils; +import androidx.annotation.CheckResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.commonmark.node.Block; import org.commonmark.node.Node; import org.commonmark.node.Visitor; @@ -15,17 +15,22 @@ import java.lang.reflect.Proxy; @SuppressWarnings({"unused", "WeakerAccess"}) public abstract class DumpNodes { + /** + * Creates String representation of a node which will be used in output + */ public interface NodeProcessor { @NonNull String process(@NonNull Node node); } @NonNull + @CheckResult public static String dump(@NonNull Node node) { return dump(node, null); } @NonNull + @CheckResult public static String dump(@NonNull Node node, @Nullable NodeProcessor nodeProcessor) { final NodeProcessor processor = nodeProcessor != null @@ -49,7 +54,9 @@ public abstract class DumpNodes { // node info builder.append(processor.process(argument)); - if (argument instanceof Block) { + // @since $SNAPSHOT; check for first child instead of casting to Block + // (regular nodes can contain other nodes, for example Text) + if (argument.getFirstChild() != null) { builder.append(" [\n"); indent.increment(); visitChildren((Visitor) proxy, argument); @@ -57,8 +64,9 @@ public abstract class DumpNodes { indent.appendTo(builder); builder.append("]\n"); } else { - builder.append('\n'); + builder.append("\n"); } + return null; } }); diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/ParserUtils.java b/markwon-core/src/main/java/io/noties/markwon/utils/ParserUtils.java new file mode 100644 index 00000000..80ad4596 --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/utils/ParserUtils.java @@ -0,0 +1,25 @@ +package io.noties.markwon.utils; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Node; + +/** + * @since $SNAPSHOT; + */ +public abstract class ParserUtils { + + public static void moveChildren(@NonNull Node to, @NonNull Node from) { + Node next = from.getNext(); + Node temp; + while (next != null) { + // appendChild would unlink passed node (thus making next info un-available) + temp = next.getNext(); + to.appendChild(next); + next = temp; + } + } + + private ParserUtils() { + } +} diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListBlock.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListBlock.java deleted file mode 100644 index 157104f4..00000000 --- a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListBlock.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.noties.markwon.ext.tasklist; - -import org.commonmark.node.CustomBlock; - -/** - * @since 1.0.1 - */ -public class TaskListBlock extends CustomBlock { -} diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListBlockParser.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListBlockParser.java deleted file mode 100644 index f65c4f7b..00000000 --- a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListBlockParser.java +++ /dev/null @@ -1,152 +0,0 @@ -package io.noties.markwon.ext.tasklist; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.commonmark.node.Block; -import org.commonmark.parser.InlineParser; -import org.commonmark.parser.block.AbstractBlockParser; -import org.commonmark.parser.block.AbstractBlockParserFactory; -import org.commonmark.parser.block.BlockContinue; -import org.commonmark.parser.block.BlockStart; -import org.commonmark.parser.block.MatchedBlockParser; -import org.commonmark.parser.block.ParserState; - -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * @since 1.0.1 - */ -@SuppressWarnings("WeakerAccess") -class TaskListBlockParser extends AbstractBlockParser { - - private static final Pattern PATTERN = Pattern.compile("\\s*([-*+]|\\d{1,9}[.)])\\s+\\[(x|X|\\s)]\\s+(.*)"); - - private final TaskListBlock block = new TaskListBlock(); - - private final List items = new ArrayList<>(3); - - private int indent = 0; - - TaskListBlockParser(@NonNull String startLine, int startIndent) { - items.add(new Item(startLine, startIndent)); - indent = startIndent; - } - - @Override - public Block getBlock() { - return block; - } - - @Override - public BlockContinue tryContinue(ParserState parserState) { - - final BlockContinue blockContinue; - - final String line = line(parserState); - - final int currentIndent = parserState.getIndent(); - if (currentIndent > indent) { - indent += 2; - } else if (currentIndent < indent && indent > 1) { - indent -= 2; - } - - if (line != null - && line.length() > 0 - && PATTERN.matcher(line).matches()) { - blockContinue = BlockContinue.atIndex(parserState.getIndex()); - } else { - // @since 2.0.0, previously called `BlockContinue.finished()` - // that was swallowing non-matching lines - blockContinue = BlockContinue.none(); - } - - return blockContinue; - } - - @Override - public void addLine(CharSequence line) { - if (length(line) > 0) { - items.add(new Item(line.toString(), indent)); - } - } - - @Override - public void parseInlines(InlineParser inlineParser) { - - Matcher matcher; - - TaskListItem listItem; - - for (Item item : items) { - matcher = PATTERN.matcher(item.line); - if (!matcher.matches()) { - continue; - } - listItem = new TaskListItem() - .done(isDone(matcher.group(2))) - .indent(item.indent / 2); - inlineParser.parse(matcher.group(3), listItem); - block.appendChild(listItem); - } - } - - static class Factory extends AbstractBlockParserFactory { - - @Override - public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { - - final String line = line(state); - - if (line != null - && line.length() > 0 - && PATTERN.matcher(line).matches()) { - - final int length = line.length(); - final int index = state.getIndex(); - final int atIndex = index != 0 - ? index + (length - index) - : length; - - return BlockStart.of(new TaskListBlockParser(line, state.getIndent())) - .atIndex(atIndex); - } - - return BlockStart.none(); - } - } - - @Nullable - private static String line(@NonNull ParserState state) { - final CharSequence lineRaw = state.getLine(); - return lineRaw != null - ? lineRaw.toString() - : null; - } - - private static int length(@Nullable CharSequence text) { - return text != null - ? text.length() - : 0; - } - - private static boolean isDone(@NonNull String value) { - return "X".equals(value) - || "x".equals(value); - } - - private static class Item { - - final String line; - final int indent; - - Item(@NonNull String line, int indent) { - this.line = line; - this.indent = indent; - } - } -} diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListItem.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListItem.java index 0ba504b8..497bddb2 100644 --- a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListItem.java +++ b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListItem.java @@ -1,31 +1,30 @@ package io.noties.markwon.ext.tasklist; -import org.commonmark.node.CustomNode; +import androidx.annotation.NonNull; + +import org.commonmark.node.CustomBlock; /** * @since 1.0.1 */ @SuppressWarnings("WeakerAccess") -public class TaskListItem extends CustomNode { +public class TaskListItem extends CustomBlock { - private boolean done; - private int indent; + private final boolean isDone; - public boolean done() { - return done; + public TaskListItem(boolean isDone) { + this.isDone = isDone; } - public TaskListItem done(boolean done) { - this.done = done; - return this; + public boolean isDone() { + return isDone; } - public int indent() { - return indent; - } - - public TaskListItem indent(int indent) { - this.indent = indent; - return this; + @Override + @NonNull + public String toString() { + return "TaskListItem{" + + "isDone=" + isDone + + '}'; } } diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListPlugin.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListPlugin.java index df71b2c4..a27aeb01 100644 --- a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListPlugin.java +++ b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListPlugin.java @@ -9,13 +9,11 @@ import androidx.annotation.AttrRes; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; -import org.commonmark.node.Node; import org.commonmark.parser.Parser; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.MarkwonSpansFactory; import io.noties.markwon.MarkwonVisitor; -import io.noties.markwon.RenderProps; import io.noties.markwon.core.SimpleBlockNodeVisitor; /** @@ -66,7 +64,7 @@ public class TaskListPlugin extends AbstractMarkwonPlugin { @Override public void configureParser(@NonNull Parser.Builder builder) { - builder.customBlockParserFactory(new TaskListBlockParser.Factory()); + builder.postProcessor(new TaskListPostProcessor()); } @Override @@ -77,7 +75,6 @@ public class TaskListPlugin extends AbstractMarkwonPlugin { @Override public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { builder - .on(TaskListBlock.class, new SimpleBlockNodeVisitor()) .on(TaskListItem.class, new MarkwonVisitor.NodeVisitor() { @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull TaskListItem taskListItem) { @@ -86,10 +83,7 @@ public class TaskListPlugin extends AbstractMarkwonPlugin { visitor.visitChildren(taskListItem); - final RenderProps context = visitor.renderProps(); - - TaskListProps.BLOCK_INDENT.set(context, indent(taskListItem) + taskListItem.indent()); - TaskListProps.DONE.set(context, taskListItem.done()); + TaskListProps.DONE.set(visitor.renderProps(), taskListItem.isDone()); visitor.setSpansForNode(taskListItem, length); @@ -110,17 +104,4 @@ public class TaskListPlugin extends AbstractMarkwonPlugin { typedArray.recycle(); } } - - private static int indent(@NonNull Node node) { - int indent = 0; - Node parent = node.getParent(); - if (parent != null) { - parent = parent.getParent(); - while (parent != null) { - indent += 1; - parent = parent.getParent(); - } - } - return indent; - } } diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListPostProcessor.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListPostProcessor.java new file mode 100644 index 00000000..34651533 --- /dev/null +++ b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListPostProcessor.java @@ -0,0 +1,82 @@ +package io.noties.markwon.ext.tasklist; + +import android.text.TextUtils; + +import org.commonmark.node.AbstractVisitor; +import org.commonmark.node.ListItem; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; +import org.commonmark.parser.PostProcessor; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.utils.ParserUtils; + +// @since $SNAPSHOT; +// Hint taken from commonmark-ext-task-list-items artifact +class TaskListPostProcessor implements PostProcessor { + + @Override + public Node process(Node node) { + final TaskListVisitor visitor = new TaskListVisitor(); + node.accept(visitor); + return node; + } + + private static class TaskListVisitor extends AbstractVisitor { + + private static final Pattern REGEX_TASK_LIST_ITEM = Pattern.compile("^\\[([xX\\s])]\\s+(.*)"); + + @Override + public void visit(ListItem listItem) { + // Takes first child and checks if it is Text (we are looking for exact `[xX\s]` without any formatting) + final Node child = listItem.getFirstChild(); + // check if it is paragraph (can contain text) + if (child instanceof Paragraph) { + final Node node = child.getFirstChild(); + if (node instanceof Text) { + + final Text textNode = (Text) node; + final Matcher matcher = REGEX_TASK_LIST_ITEM.matcher(textNode.getLiteral()); + + if (matcher.matches()) { + final String checked = matcher.group(1); + final boolean isChecked = "x".equals(checked) || "X".equals(checked); + + final TaskListItem taskListItem = new TaskListItem(isChecked); + + final Paragraph paragraph = new Paragraph(); + + // insert before list item (directly before inside parent) + listItem.insertBefore(taskListItem); + + // append the rest of matched text (can be empty) + final String restMatchedText = matcher.group(2); + if (!TextUtils.isEmpty(restMatchedText)) { + paragraph.appendChild(new Text(restMatchedText)); + } + + // move all the rest children (from the first paragraph) + ParserUtils.moveChildren(paragraph, node); + + // append our created paragraph + taskListItem.appendChild(paragraph); + + // move all the rest children from the listItem (further nested lists, etc) + ParserUtils.moveChildren(taskListItem, child); + + // remove list item from node + listItem.unlink(); + + // visit taskListItem children + visitChildren(taskListItem); + return; + } + } + } + visitChildren(listItem); + } + } +} diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListProps.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListProps.java index 9e02990b..2b13231f 100644 --- a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListProps.java +++ b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListProps.java @@ -7,8 +7,6 @@ import io.noties.markwon.Prop; */ public abstract class TaskListProps { - public static final Prop BLOCK_INDENT = Prop.of("task-list-block-indent"); - public static final Prop DONE = Prop.of("task-list-done"); private TaskListProps() { diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpan.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpan.java index 04ba8dbd..c45295c2 100644 --- a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpan.java +++ b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpan.java @@ -22,16 +22,13 @@ public class TaskListSpan implements LeadingMarginSpan { private final MarkwonTheme theme; private final Drawable drawable; - private final int blockIndent; // @since 2.0.1 field is NOT final (to allow mutation) private boolean isDone; - - public TaskListSpan(@NonNull MarkwonTheme theme, @NonNull Drawable drawable, int blockIndent, boolean isDone) { + public TaskListSpan(@NonNull MarkwonTheme theme, @NonNull Drawable drawable, boolean isDone) { this.theme = theme; this.drawable = drawable; - this.blockIndent = blockIndent; this.isDone = isDone; } @@ -54,7 +51,7 @@ public class TaskListSpan implements LeadingMarginSpan { @Override public int getLeadingMargin(boolean first) { - return theme.getBlockMargin() * blockIndent; + return theme.getBlockMargin(); } @Override @@ -65,11 +62,14 @@ public class TaskListSpan implements LeadingMarginSpan { return; } + final float descent = p.descent(); + final float ascent = p.ascent(); + final int save = c.save(); try { final int width = theme.getBlockMargin(); - final int height = bottom - top; + final int height = (int) (descent - ascent + 0.5F); final int w = (int) (width * .75F + .5F); final int h = (int) (height * .75F + .5F); @@ -88,12 +88,12 @@ public class TaskListSpan implements LeadingMarginSpan { final int l; if (dir > 0) { - l = x + (width * (blockIndent - 1)) + ((width - w) / 2); + l = x + ((width - w) / 2); } else { - l = x - (width * blockIndent) + ((width - w) / 2); + l = x - ((width - w) / 2) - w; } - final int t = top + ((height - h) / 2); + final int t = (int) (baseline + ascent + 0.5F) + ((height - h) / 2); c.translate(l, t); drawable.draw(c); diff --git a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpanFactory.java b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpanFactory.java index c5d64d60..6b0aa410 100644 --- a/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpanFactory.java +++ b/markwon-ext-tasklist/src/main/java/io/noties/markwon/ext/tasklist/TaskListSpanFactory.java @@ -23,7 +23,6 @@ public class TaskListSpanFactory implements SpanFactory { return new TaskListSpan( configuration.theme(), drawable, - TaskListProps.BLOCK_INDENT.get(props, 0), TaskListProps.DONE.get(props, false) ); } diff --git a/markwon-ext-tasklist/src/test/java/io/noties/markwon/ext/tasklist/TaskListTest.java b/markwon-ext-tasklist/src/test/java/io/noties/markwon/ext/tasklist/TaskListTest.java index eefc50ab..f7ca0b7d 100644 --- a/markwon-ext-tasklist/src/test/java/io/noties/markwon/ext/tasklist/TaskListTest.java +++ b/markwon-ext-tasklist/src/test/java/io/noties/markwon/ext/tasklist/TaskListTest.java @@ -21,8 +21,6 @@ import io.noties.markwon.SpanFactory; import io.noties.markwon.test.TestSpan; import io.noties.markwon.test.TestSpanMatcher; -import static io.noties.markwon.test.TestSpan.span; - @RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE) public class TaskListTest { @@ -33,6 +31,9 @@ public class TaskListTest { @Test public void test() { + // NB! different markers lead to different types of lists, + // that's why there are 2 new lines after each type + final TestSpan.Document document = TestSpan.document( TestSpan.span(SPAN, TestSpan.args(IS_DONE, false), TestSpan.text("First")), newLine(), @@ -40,20 +41,24 @@ public class TaskListTest { newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, true), TestSpan.text("Third")), newLine(), + newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, false), TestSpan.text("First star")), newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, true), TestSpan.text("Second star")), newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, true), TestSpan.text("Third star")), newLine(), + newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, false), TestSpan.text("First plus")), newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, true), TestSpan.text("Second plus")), newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, true), TestSpan.text("Third plus")), newLine(), + newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, true), TestSpan.text("Number with dot")), newLine(), + newLine(), TestSpan.span(SPAN, TestSpan.args(IS_DONE, false), TestSpan.text("Number")) );