It is alive
This commit is contained in:
		
							parent
							
								
									75c3aa8102
								
							
						
					
					
						commit
						e95defb67c
					
				| @ -18,11 +18,18 @@ import android.widget.TextView; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.internal.inline.AsteriskDelimiterProcessor; | ||||
| import org.commonmark.internal.inline.UnderscoreDelimiterProcessor; | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.Text; | ||||
| import org.commonmark.parser.Parser; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.Executors; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| @ -38,6 +45,15 @@ import io.noties.markwon.editor.handler.StrongEmphasisEditHandler; | ||||
| import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; | ||||
| import io.noties.markwon.linkify.LinkifyPlugin; | ||||
| import io.noties.markwon.sample.R; | ||||
| import io.noties.markwon.sample.editor.inline.AutolinkInline; | ||||
| import io.noties.markwon.sample.editor.inline.BackslashInline; | ||||
| import io.noties.markwon.sample.editor.inline.BackticksInline; | ||||
| import io.noties.markwon.sample.editor.inline.CloseBracketInline; | ||||
| import io.noties.markwon.sample.editor.inline.EntityInline; | ||||
| import io.noties.markwon.sample.editor.inline.HtmlInline; | ||||
| import io.noties.markwon.sample.editor.inline.Inline; | ||||
| import io.noties.markwon.sample.editor.inline.InlineParserImpl; | ||||
| import io.noties.markwon.sample.editor.inline.NewLineInline; | ||||
| 
 | ||||
| public class EditorActivity extends Activity { | ||||
| 
 | ||||
| @ -102,52 +118,52 @@ public class EditorActivity extends Activity { | ||||
|     private void additional_edit_span() { | ||||
|         // An additional span is used to highlight strong-emphasis | ||||
| 
 | ||||
| final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||
|         .useEditHandler(new AbstractEditHandler<StrongEmphasisSpan>() { | ||||
|             @Override | ||||
|             public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { | ||||
|                 // Here we define which span is _persisted_ in EditText, it is not removed | ||||
|                 //  from EditText between text changes, but instead - reused (by changing | ||||
|                 //  position). Consider it as a cache for spans. We could use `StrongEmphasisSpan` | ||||
|                 //  here also, but I chose Bold to indicate that this span is not the same | ||||
|                 //  as in off-screen rendered markdown | ||||
|                 builder.persistSpan(Bold.class, Bold::new); | ||||
|             } | ||||
|         final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||
|                 .useEditHandler(new AbstractEditHandler<StrongEmphasisSpan>() { | ||||
|                     @Override | ||||
|                     public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { | ||||
|                         // Here we define which span is _persisted_ in EditText, it is not removed | ||||
|                         //  from EditText between text changes, but instead - reused (by changing | ||||
|                         //  position). Consider it as a cache for spans. We could use `StrongEmphasisSpan` | ||||
|                         //  here also, but I chose Bold to indicate that this span is not the same | ||||
|                         //  as in off-screen rendered markdown | ||||
|                         builder.persistSpan(Bold.class, Bold::new); | ||||
|                     } | ||||
| 
 | ||||
|             @Override | ||||
|             public void handleMarkdownSpan( | ||||
|                     @NonNull PersistedSpans persistedSpans, | ||||
|                     @NonNull Editable editable, | ||||
|                     @NonNull String input, | ||||
|                     @NonNull StrongEmphasisSpan span, | ||||
|                     int spanStart, | ||||
|                     int spanTextLength) { | ||||
|                 // Unfortunately we cannot hardcode delimiters length here (aka spanTextLength + 4) | ||||
|                 //  because multiple inline markdown nodes can refer to the same text. | ||||
|                 //  For example, `**_~~hey~~_**` - we will receive `**_~~` in this method, | ||||
|                 //  and thus will have to manually find actual position in raw user input | ||||
|                 final MarkwonEditorUtils.Match match = | ||||
|                         MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__"); | ||||
|                 if (match != null) { | ||||
|                     editable.setSpan( | ||||
|                             // we handle StrongEmphasisSpan and represent it with Bold in EditText | ||||
|                             //  we still could use StrongEmphasisSpan, but it must be accessed | ||||
|                             //  via persistedSpans | ||||
|                             persistedSpans.get(Bold.class), | ||||
|                             match.start(), | ||||
|                             match.end(), | ||||
|                             Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
|                     @Override | ||||
|                     public void handleMarkdownSpan( | ||||
|                             @NonNull PersistedSpans persistedSpans, | ||||
|                             @NonNull Editable editable, | ||||
|                             @NonNull String input, | ||||
|                             @NonNull StrongEmphasisSpan span, | ||||
|                             int spanStart, | ||||
|                             int spanTextLength) { | ||||
|                         // Unfortunately we cannot hardcode delimiters length here (aka spanTextLength + 4) | ||||
|                         //  because multiple inline markdown nodes can refer to the same text. | ||||
|                         //  For example, `**_~~hey~~_**` - we will receive `**_~~` in this method, | ||||
|                         //  and thus will have to manually find actual position in raw user input | ||||
|                         final MarkwonEditorUtils.Match match = | ||||
|                                 MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__"); | ||||
|                         if (match != null) { | ||||
|                             editable.setSpan( | ||||
|                                     // we handle StrongEmphasisSpan and represent it with Bold in EditText | ||||
|                                     //  we still could use StrongEmphasisSpan, but it must be accessed | ||||
|                                     //  via persistedSpans | ||||
|                                     persistedSpans.get(Bold.class), | ||||
|                                     match.start(), | ||||
|                                     match.end(), | ||||
|                                     Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ||||
|                             ); | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public Class<StrongEmphasisSpan> markdownSpanType() { | ||||
|                 return StrongEmphasisSpan.class; | ||||
|             } | ||||
|         }) | ||||
|         .build(); | ||||
|                     @NonNull | ||||
|                     @Override | ||||
|                     public Class<StrongEmphasisSpan> markdownSpanType() { | ||||
|                         return StrongEmphasisSpan.class; | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|     } | ||||
| @ -171,6 +187,67 @@ final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||
|         // for links to be clickable | ||||
|         editText.setMovementMethod(LinkMovementMethod.getInstance()); | ||||
| 
 | ||||
|         // provider? | ||||
|         final InlineParserImpl.Builder inlineParserFactoryBuilder = InlineParserImpl.builder() | ||||
|                 .addDelimiterProcessor(new AsteriskDelimiterProcessor()) | ||||
|                 .addDelimiterProcessor(new UnderscoreDelimiterProcessor()) | ||||
|                 .addInlineProcessor(new AutolinkInline()) | ||||
|                 .addInlineProcessor(new BackslashInline()) | ||||
|                 .addInlineProcessor(new BackticksInline()) | ||||
| //                .addInlineProcessor(new BangInline()) // no images then | ||||
|                 .addInlineProcessor(new CloseBracketInline()) | ||||
|                 .addInlineProcessor(new EntityInline()) | ||||
|                 .addInlineProcessor(new HtmlInline()) | ||||
|                 .addInlineProcessor(new NewLineInline()) | ||||
|                 .addInlineProcessor(new Inline() { | ||||
| 
 | ||||
|                     private final Pattern RE = Pattern.compile("\\d+"); | ||||
| 
 | ||||
|                     @NonNull | ||||
|                     @Override | ||||
|                     public Collection<Character> characters() { | ||||
|                         return Collections.singleton('#'); | ||||
|                     } | ||||
| 
 | ||||
|                     @Override | ||||
|                     public boolean parse() { | ||||
|                         final String id = match(RE); | ||||
|                         if (id != null) { | ||||
|                             final Link link = new Link("https://github.com/noties/Markwon/issues/" + id, null); | ||||
|                             final Text text = new Text("#" + id); | ||||
|                             link.appendChild(text); | ||||
|                             appendNode(link); | ||||
|                             return true; | ||||
|                         } | ||||
|                         return false; | ||||
|                     } | ||||
|                 }) | ||||
|                 .addInlineProcessor(new Inline() { | ||||
| 
 | ||||
|                     private final Pattern RE = Pattern.compile("\\w+"); | ||||
| 
 | ||||
|                     @NonNull | ||||
|                     @Override | ||||
|                     public Collection<Character> characters() { | ||||
|                         return Collections.singleton('#'); | ||||
|                     } | ||||
| 
 | ||||
|                     @Override | ||||
|                     public boolean parse() { | ||||
|                         final String s = match(RE); | ||||
|                         if (s != null) { | ||||
|                             final Link link = new Link("https://noties.io", null); | ||||
|                             final Text text = new Text("#" + s); | ||||
|                             link.appendChild(text); | ||||
|                             appendNode(link); | ||||
|                             return true; | ||||
|                         } | ||||
|                         return false; | ||||
|                     } | ||||
|                 }) | ||||
| //                .addInlineProcessor(new OpenBracketInline()) | ||||
|                 ; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(StrikethroughPlugin.create()) | ||||
|                 .usePlugin(LinkifyPlugin.create()) | ||||
| @ -179,6 +256,7 @@ final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                         // disable all commonmark-java blocks, only inlines will be parsed | ||||
| //                        builder.enabledBlockTypes(Collections.emptySet()); | ||||
|                         builder.inlineParserFactory(inlineParserFactoryBuilder.build()); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
|  | ||||
| @ -0,0 +1,45 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| public class AutolinkInline extends Inline { | ||||
| 
 | ||||
|     private static final Pattern EMAIL_AUTOLINK = Pattern | ||||
|             .compile("^<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>"); | ||||
| 
 | ||||
|     private static final Pattern AUTOLINK = Pattern | ||||
|             .compile("^<[a-zA-Z][a-zA-Z0-9.+-]{1,31}:[^<>\u0000-\u0020]*>"); | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<Character> characters() { | ||||
|         return Collections.singleton('<'); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean parse() { | ||||
|         String m; | ||||
|         if ((m = match(EMAIL_AUTOLINK)) != null) { | ||||
|             String dest = m.substring(1, m.length() - 1); | ||||
|             Link node = new Link("mailto:" + dest, null); | ||||
|             node.appendChild(new Text(dest)); | ||||
|             appendNode(node); | ||||
|             return true; | ||||
|         } else if ((m = match(AUTOLINK)) != null) { | ||||
|             String dest = m.substring(1, m.length() - 1); | ||||
|             Link node = new Link(dest, null); | ||||
|             node.appendChild(new Text(dest)); | ||||
|             appendNode(node); | ||||
|             return true; | ||||
|         } else { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,31 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.node.HardLineBreak; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| public class BackslashInline extends Inline { | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<Character> characters() { | ||||
|         return Collections.singleton('\\'); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean parse() { | ||||
|         index++; | ||||
|         if (peek() == '\n') { | ||||
|             appendNode(new HardLineBreak()); | ||||
|             index++; | ||||
|         } else if (index < input.length() && ESCAPABLE.matcher(input.substring(index, index + 1)).matches()) { | ||||
|             appendText(input, index, index + 1); | ||||
|             index++; | ||||
|         } else { | ||||
|             appendText("\\"); | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,46 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.node.Code; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| public class BackticksInline extends Inline { | ||||
| 
 | ||||
|     private static final Pattern TICKS = Pattern.compile("`+"); | ||||
| 
 | ||||
|     private static final Pattern TICKS_HERE = Pattern.compile("^`+"); | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<Character> characters() { | ||||
|         return Collections.singleton('`'); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean parse() { | ||||
|         String ticks = match(TICKS_HERE); | ||||
|         if (ticks == null) { | ||||
|             return false; | ||||
|         } | ||||
|         int afterOpenTicks = index; | ||||
|         String matched; | ||||
|         while ((matched = match(TICKS)) != null) { | ||||
|             if (matched.equals(ticks)) { | ||||
|                 Code node = new Code(); | ||||
|                 String content = input.substring(afterOpenTicks, index - ticks.length()); | ||||
|                 String literal = WHITESPACE.matcher(content.trim()).replaceAll(" "); | ||||
|                 node.setLiteral(literal); | ||||
|                 appendNode(node); | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|         // If we got here, we didn't match a closing backtick sequence. | ||||
|         index = afterOpenTicks; | ||||
|         appendText(ticks); | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,34 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| public class BangInline extends Inline { | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<Character> characters() { | ||||
|         return Collections.singleton('!'); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean parse() { | ||||
|         int startIndex = index; | ||||
|         index++; | ||||
|         if (peek() == '[') { | ||||
|             index++; | ||||
| 
 | ||||
|             Text node = appendText("!["); | ||||
| 
 | ||||
|             // Add entry to stack for this opener | ||||
|             addBracket(Bracket.image(node, startIndex + 1, lastBracket(), lastDelimiter())); | ||||
|         } else { | ||||
|             appendText("!"); | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,135 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.util.Escaping; | ||||
| import org.commonmark.node.Image; | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.Node; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| public class CloseBracketInline extends Inline { | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<Character> characters() { | ||||
|         return Collections.singleton(']'); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean parse() { | ||||
|         index++; | ||||
|         int startIndex = index; | ||||
| 
 | ||||
|         // Get previous `[` or `![` | ||||
|         Bracket opener = lastBracket(); | ||||
|         if (opener == null) { | ||||
|             // No matching opener, just return a literal. | ||||
|             appendText("]"); | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         if (!opener.allowed) { | ||||
|             // Matching opener but it's not allowed, just return a literal. | ||||
|             appendText("]"); | ||||
|             removeLastBracket(); | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         // Check to see if we have a link/image | ||||
| 
 | ||||
|         String dest = null; | ||||
|         String title = null; | ||||
|         boolean isLinkOrImage = false; | ||||
| 
 | ||||
|         // Maybe a inline link like `[foo](/uri "title")` | ||||
|         if (peek() == '(') { | ||||
|             index++; | ||||
|             spnl(); | ||||
|             if ((dest = parseLinkDestination()) != null) { | ||||
|                 spnl(); | ||||
|                 // title needs a whitespace before | ||||
|                 if (WHITESPACE.matcher(input.substring(index - 1, index)).matches()) { | ||||
|                     title = parseLinkTitle(); | ||||
|                     spnl(); | ||||
|                 } | ||||
|                 if (peek() == ')') { | ||||
|                     index++; | ||||
|                     isLinkOrImage = true; | ||||
|                 } else { | ||||
|                     index = startIndex; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Maybe a reference link like `[foo][bar]`, `[foo][]` or `[foo]` | ||||
|         if (!isLinkOrImage) { | ||||
| 
 | ||||
|             // See if there's a link label like `[bar]` or `[]` | ||||
|             int beforeLabel = index; | ||||
|             int labelLength = parseLinkLabel(); | ||||
|             String ref = null; | ||||
|             if (labelLength > 2) { | ||||
|                 ref = input.substring(beforeLabel, beforeLabel + labelLength); | ||||
|             } else if (!opener.bracketAfter) { | ||||
|                 // If the second label is empty `[foo][]` or missing `[foo]`, then the first label is the reference. | ||||
|                 // But it can only be a reference when there's no (unescaped) bracket in it. | ||||
|                 // If there is, we don't even need to try to look up the reference. This is an optimization. | ||||
|                 ref = input.substring(opener.index, startIndex); | ||||
|             } | ||||
| 
 | ||||
|             if (ref != null) { | ||||
|                 Link link = referenceMap().get(Escaping.normalizeReference(ref)); | ||||
|                 if (link != null) { | ||||
|                     dest = link.getDestination(); | ||||
|                     title = link.getTitle(); | ||||
|                     isLinkOrImage = true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (isLinkOrImage) { | ||||
|             // If we got here, open is a potential opener | ||||
|             Node linkOrImage = opener.image ? new Image(dest, title) : new Link(dest, title); | ||||
| 
 | ||||
|             Node node = opener.node.getNext(); | ||||
|             while (node != null) { | ||||
|                 Node next = node.getNext(); | ||||
|                 linkOrImage.appendChild(node); | ||||
|                 node = next; | ||||
|             } | ||||
|             appendNode(linkOrImage); | ||||
| 
 | ||||
|             // Process delimiters such as emphasis inside link/image | ||||
|             processDelimiters(opener.previousDelimiter); | ||||
|             mergeChildTextNodes(linkOrImage); | ||||
|             // We don't need the corresponding text node anymore, we turned it into a link/image node | ||||
|             opener.node.unlink(); | ||||
|             removeLastBracket(); | ||||
| 
 | ||||
|             // Links within links are not allowed. We found this link, so there can be no other link around it. | ||||
|             if (!opener.image) { | ||||
|                 Bracket bracket = lastBracket(); | ||||
|                 while (bracket != null) { | ||||
|                     if (!bracket.image) { | ||||
|                         // Disallow link opener. It will still get matched, but will not result in a link. | ||||
|                         bracket.allowed = false; | ||||
|                     } | ||||
|                     bracket = bracket.previous; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             return true; | ||||
| 
 | ||||
|         } else { // no link or image | ||||
| 
 | ||||
|             appendText("]"); | ||||
|             removeLastBracket(); | ||||
| 
 | ||||
|             index = startIndex; | ||||
|             return true; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,32 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.internal.util.Html5Entities; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| public class EntityInline extends Inline { | ||||
| 
 | ||||
|     private static final String ENTITY = "&(?:#x[a-f0-9]{1,8}|#[0-9]{1,8}|[a-z][a-z0-9]{1,31});"; | ||||
|     private static final Pattern ENTITY_HERE = Pattern.compile('^' + ENTITY, Pattern.CASE_INSENSITIVE); | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<Character> characters() { | ||||
|         return Collections.singleton('&'); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean parse() { | ||||
|         String m; | ||||
|         if ((m = match(ENTITY_HERE)) != null) { | ||||
|             appendText(Html5Entities.entityToString(m)); | ||||
|             return true; | ||||
|         } else { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,39 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.internal.util.Parsing; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| public class HtmlInline extends Inline { | ||||
| 
 | ||||
|     private static final String HTMLCOMMENT = "<!---->|<!--(?:-?[^>-])(?:-?[^-])*-->"; | ||||
|     private static final String PROCESSINGINSTRUCTION = "[<][?].*?[?][>]"; | ||||
|     private static final String DECLARATION = "<![A-Z]+\\s+[^>]*>"; | ||||
|     private static final String CDATA = "<!\\[CDATA\\[[\\s\\S]*?\\]\\]>"; | ||||
|     private static final String HTMLTAG = "(?:" + Parsing.OPENTAG + "|" + Parsing.CLOSETAG + "|" + HTMLCOMMENT | ||||
|             + "|" + PROCESSINGINSTRUCTION + "|" + DECLARATION + "|" + CDATA + ")"; | ||||
|     private static final Pattern HTML_TAG = Pattern.compile('^' + HTMLTAG, Pattern.CASE_INSENSITIVE); | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<Character> characters() { | ||||
|         return Collections.singleton('<'); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean parse() { | ||||
|         String m = match(HTML_TAG); | ||||
|         if (m != null) { | ||||
|             org.commonmark.node.HtmlInline node = new org.commonmark.node.HtmlInline(); | ||||
|             node.setLiteral(m); | ||||
|             appendNode(node); | ||||
|             return true; | ||||
|         } else { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,429 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.Delimiter; | ||||
| import org.commonmark.internal.util.Escaping; | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| import org.commonmark.parser.delimiter.DelimiterProcessor; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.HashMap; | ||||
| import java.util.Map; | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| public abstract class Inline { | ||||
| 
 | ||||
|     private static final String ESCAPED_CHAR = "\\\\" + Escaping.ESCAPABLE; | ||||
| 
 | ||||
|     protected static final Pattern ESCAPABLE = Pattern.compile('^' + Escaping.ESCAPABLE); | ||||
| 
 | ||||
|     protected static final Pattern WHITESPACE = Pattern.compile("\\s+"); | ||||
| 
 | ||||
|     protected static final Pattern SPNL = Pattern.compile("^ *(?:\n *)?"); | ||||
| 
 | ||||
|     protected static final Pattern LINK_TITLE = Pattern.compile( | ||||
|             "^(?:\"(" + ESCAPED_CHAR + "|[^\"\\x00])*\"" + | ||||
|                     '|' + | ||||
|                     "'(" + ESCAPED_CHAR + "|[^'\\x00])*'" + | ||||
|                     '|' + | ||||
|                     "\\((" + ESCAPED_CHAR + "|[^)\\x00])*\\))"); | ||||
| 
 | ||||
|     protected static final Pattern LINK_DESTINATION_BRACES = Pattern.compile("^(?:[<](?:[^<> \\t\\n\\\\]|\\\\.)*[>])"); | ||||
| 
 | ||||
|     protected static final Pattern LINK_LABEL = Pattern.compile("^\\[(?:[^\\\\\\[\\]]|\\\\.)*\\]"); | ||||
| 
 | ||||
| 
 | ||||
|     protected InlineContext context; | ||||
|     protected Node block; | ||||
|     protected int index; | ||||
|     protected String input; | ||||
| 
 | ||||
|     protected void bind( | ||||
|             @NonNull InlineContext context, | ||||
|             @NonNull Node block, | ||||
|             @NonNull String input, | ||||
|             int index) { | ||||
|         this.context = context; | ||||
|         this.block = block; | ||||
|         this.input = input; | ||||
|         this.index = index; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public abstract Collection<Character> characters(); | ||||
| 
 | ||||
|     public abstract boolean parse(); | ||||
| 
 | ||||
|     /** | ||||
|      * If RE matches at current index in the input, advance index and return the match; otherwise return null. | ||||
|      */ | ||||
|     protected String match(Pattern re) { | ||||
|         if (index >= input.length()) { | ||||
|             return null; | ||||
|         } | ||||
|         Matcher matcher = re.matcher(input); | ||||
|         matcher.region(index, input.length()); | ||||
|         boolean m = matcher.find(); | ||||
|         if (m) { | ||||
|             index = matcher.end(); | ||||
|             return matcher.group(); | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected void appendNode(Node node) { | ||||
|         block.appendChild(node); | ||||
|     } | ||||
| 
 | ||||
|     protected Text appendText(CharSequence text, int beginIndex, int endIndex) { | ||||
|         return appendText(text.subSequence(beginIndex, endIndex)); | ||||
|     } | ||||
| 
 | ||||
|     protected Text appendText(CharSequence text) { | ||||
|         Text node = new Text(text.toString()); | ||||
|         appendNode(node); | ||||
|         return node; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the char at the current input index, or {@code '\0'} in case there are no more characters. | ||||
|      */ | ||||
|     protected char peek() { | ||||
|         if (index < input.length()) { | ||||
|             return input.charAt(index); | ||||
|         } else { | ||||
|             return '\0'; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected void addBracket(Bracket bracket) { | ||||
|         final Bracket lastBracket = context.lastBracket(); | ||||
|         if (lastBracket != null) { | ||||
|             lastBracket.bracketAfter = true; | ||||
|         } | ||||
|         context.lastBracket(bracket); | ||||
|     } | ||||
| 
 | ||||
|     protected void removeLastBracket() { | ||||
|         final InlineContext context = this.context; | ||||
|         context.lastBracket(context.lastBracket().previous); | ||||
|     } | ||||
| 
 | ||||
|     protected Bracket lastBracket() { | ||||
|         return context.lastBracket(); | ||||
|     } | ||||
| 
 | ||||
|     protected Delimiter lastDelimiter() { | ||||
|         return context.lastDelimiter(); | ||||
|     } | ||||
| 
 | ||||
|     protected Map<String, Link> referenceMap() { | ||||
|         return context.referenceMap(); | ||||
|     } | ||||
| 
 | ||||
|     protected Map<Character, DelimiterProcessor> delimiterProcessors() { | ||||
|         return context.delimiterProcessors(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Parse zero or more space characters, including at most one newline. | ||||
|      */ | ||||
|     protected boolean spnl() { | ||||
|         match(SPNL); | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Attempt to parse link destination, returning the string or null if no match. | ||||
|      */ | ||||
|     protected String parseLinkDestination() { | ||||
|         String res = match(LINK_DESTINATION_BRACES); | ||||
|         if (res != null) { // chop off surrounding <..>: | ||||
|             if (res.length() == 2) { | ||||
|                 return ""; | ||||
|             } else { | ||||
|                 return Escaping.unescapeString(res.substring(1, res.length() - 1)); | ||||
|             } | ||||
|         } else { | ||||
|             int startIndex = index; | ||||
|             parseLinkDestinationWithBalancedParens(); | ||||
|             return Escaping.unescapeString(input.substring(startIndex, index)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected void parseLinkDestinationWithBalancedParens() { | ||||
|         int parens = 0; | ||||
|         while (true) { | ||||
|             char c = peek(); | ||||
|             switch (c) { | ||||
|                 case '\0': | ||||
|                     return; | ||||
|                 case '\\': | ||||
|                     // check if we have an escapable character | ||||
|                     if (index + 1 < input.length() && ESCAPABLE.matcher(input.substring(index + 1, index + 2)).matches()) { | ||||
|                         // skip over the escaped character (after switch) | ||||
|                         index++; | ||||
|                         break; | ||||
|                     } | ||||
|                     // otherwise, we treat this as a literal backslash | ||||
|                     break; | ||||
|                 case '(': | ||||
|                     parens++; | ||||
|                     break; | ||||
|                 case ')': | ||||
|                     if (parens == 0) { | ||||
|                         return; | ||||
|                     } else { | ||||
|                         parens--; | ||||
|                     } | ||||
|                     break; | ||||
|                 case ' ': | ||||
|                     // ASCII space | ||||
|                     return; | ||||
|                 default: | ||||
|                     // or control character | ||||
|                     if (Character.isISOControl(c)) { | ||||
|                         return; | ||||
|                     } | ||||
|             } | ||||
|             index++; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Attempt to parse link title (sans quotes), returning the string or null if no match. | ||||
|      */ | ||||
|     protected String parseLinkTitle() { | ||||
|         String title = match(LINK_TITLE); | ||||
|         if (title != null) { | ||||
|             // chop off quotes from title and unescape: | ||||
|             return Escaping.unescapeString(title.substring(1, title.length() - 1)); | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Attempt to parse a link label, returning number of characters parsed. | ||||
|      */ | ||||
|     protected int parseLinkLabel() { | ||||
|         String m = match(LINK_LABEL); | ||||
|         // Spec says "A link label can have at most 999 characters inside the square brackets" | ||||
|         if (m == null || m.length() > 1001) { | ||||
|             return 0; | ||||
|         } else { | ||||
|             return m.length(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected void processDelimiters(Delimiter stackBottom) { | ||||
| 
 | ||||
|         Map<Character, Delimiter> openersBottom = new HashMap<>(); | ||||
| 
 | ||||
|         // find first closer above stackBottom: | ||||
|         Delimiter closer = lastDelimiter(); | ||||
|         while (closer != null && closer.previous != stackBottom) { | ||||
|             closer = closer.previous; | ||||
|         } | ||||
|         // move forward, looking for closers, and handling each | ||||
|         while (closer != null) { | ||||
|             char delimiterChar = closer.delimiterChar; | ||||
| 
 | ||||
|             DelimiterProcessor delimiterProcessor = delimiterProcessors().get(delimiterChar); | ||||
|             if (!closer.canClose || delimiterProcessor == null) { | ||||
|                 closer = closer.next; | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
|             char openingDelimiterChar = delimiterProcessor.getOpeningCharacter(); | ||||
| 
 | ||||
|             // Found delimiter closer. Now look back for first matching opener. | ||||
|             int useDelims = 0; | ||||
|             boolean openerFound = false; | ||||
|             boolean potentialOpenerFound = false; | ||||
|             Delimiter opener = closer.previous; | ||||
|             while (opener != null && opener != stackBottom && opener != openersBottom.get(delimiterChar)) { | ||||
|                 if (opener.canOpen && opener.delimiterChar == openingDelimiterChar) { | ||||
|                     potentialOpenerFound = true; | ||||
|                     useDelims = delimiterProcessor.getDelimiterUse(opener, closer); | ||||
|                     if (useDelims > 0) { | ||||
|                         openerFound = true; | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
|                 opener = opener.previous; | ||||
|             } | ||||
| 
 | ||||
|             if (!openerFound) { | ||||
|                 if (!potentialOpenerFound) { | ||||
|                     // Set lower bound for future searches for openers. | ||||
|                     // Only do this when we didn't even have a potential | ||||
|                     // opener (one that matches the character and can open). | ||||
|                     // If an opener was rejected because of the number of | ||||
|                     // delimiters (e.g. because of the "multiple of 3" rule), | ||||
|                     // we want to consider it next time because the number | ||||
|                     // of delimiters can change as we continue processing. | ||||
|                     openersBottom.put(delimiterChar, closer.previous); | ||||
|                     if (!closer.canOpen) { | ||||
|                         // We can remove a closer that can't be an opener, | ||||
|                         // once we've seen there's no matching opener: | ||||
|                         removeDelimiterKeepNode(closer); | ||||
|                     } | ||||
|                 } | ||||
|                 closer = closer.next; | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
|             Text openerNode = opener.node; | ||||
|             Text closerNode = closer.node; | ||||
| 
 | ||||
|             // Remove number of used delimiters from stack and inline nodes. | ||||
|             opener.length -= useDelims; | ||||
|             closer.length -= useDelims; | ||||
|             openerNode.setLiteral( | ||||
|                     openerNode.getLiteral().substring(0, | ||||
|                             openerNode.getLiteral().length() - useDelims)); | ||||
|             closerNode.setLiteral( | ||||
|                     closerNode.getLiteral().substring(0, | ||||
|                             closerNode.getLiteral().length() - useDelims)); | ||||
| 
 | ||||
|             removeDelimitersBetween(opener, closer); | ||||
|             // The delimiter processor can re-parent the nodes between opener and closer, | ||||
|             // so make sure they're contiguous already. Exclusive because we want to keep opener/closer themselves. | ||||
|             mergeTextNodesBetweenExclusive(openerNode, closerNode); | ||||
|             delimiterProcessor.process(openerNode, closerNode, useDelims); | ||||
| 
 | ||||
|             // No delimiter characters left to process, so we can remove delimiter and the now empty node. | ||||
|             if (opener.length == 0) { | ||||
|                 removeDelimiterAndNode(opener); | ||||
|             } | ||||
| 
 | ||||
|             if (closer.length == 0) { | ||||
|                 Delimiter next = closer.next; | ||||
|                 removeDelimiterAndNode(closer); | ||||
|                 closer = next; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // remove all delimiters | ||||
|         Delimiter lastDelimiter; | ||||
|         while ((lastDelimiter = lastDelimiter()) != null) { | ||||
|             if (lastDelimiter != stackBottom) { | ||||
|                 removeDelimiterKeepNode(lastDelimiter); | ||||
|             } else { | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| //        while (lastDelimiter != null && lastDelimiter != stackBottom) { | ||||
| //            removeDelimiterKeepNode(lastDelimiter); | ||||
| //        } | ||||
|     } | ||||
| 
 | ||||
|     private void mergeTextNodesBetweenExclusive(Node fromNode, Node toNode) { | ||||
|         // No nodes between them | ||||
|         if (fromNode == toNode || fromNode.getNext() == toNode) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         mergeTextNodesInclusive(fromNode.getNext(), toNode.getPrevious()); | ||||
|     } | ||||
| 
 | ||||
|     protected void mergeChildTextNodes(Node node) { | ||||
|         // No children or just one child node, no need for merging | ||||
|         if (node.getFirstChild() == node.getLastChild()) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         mergeTextNodesInclusive(node.getFirstChild(), node.getLastChild()); | ||||
|     } | ||||
| 
 | ||||
|     protected void mergeTextNodesInclusive(Node fromNode, Node toNode) { | ||||
|         Text first = null; | ||||
|         Text last = null; | ||||
|         int length = 0; | ||||
| 
 | ||||
|         Node node = fromNode; | ||||
|         while (node != null) { | ||||
|             if (node instanceof Text) { | ||||
|                 Text text = (Text) node; | ||||
|                 if (first == null) { | ||||
|                     first = text; | ||||
|                 } | ||||
|                 length += text.getLiteral().length(); | ||||
|                 last = text; | ||||
|             } else { | ||||
|                 mergeIfNeeded(first, last, length); | ||||
|                 first = null; | ||||
|                 last = null; | ||||
|                 length = 0; | ||||
|             } | ||||
|             if (node == toNode) { | ||||
|                 break; | ||||
|             } | ||||
|             node = node.getNext(); | ||||
|         } | ||||
| 
 | ||||
|         mergeIfNeeded(first, last, length); | ||||
|     } | ||||
| 
 | ||||
|     protected void mergeIfNeeded(Text first, Text last, int textLength) { | ||||
|         if (first != null && last != null && first != last) { | ||||
|             StringBuilder sb = new StringBuilder(textLength); | ||||
|             sb.append(first.getLiteral()); | ||||
|             Node node = first.getNext(); | ||||
|             Node stop = last.getNext(); | ||||
|             while (node != stop) { | ||||
|                 sb.append(((Text) node).getLiteral()); | ||||
|                 Node unlink = node; | ||||
|                 node = node.getNext(); | ||||
|                 unlink.unlink(); | ||||
|             } | ||||
|             String literal = sb.toString(); | ||||
|             first.setLiteral(literal); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected void removeDelimitersBetween(Delimiter opener, Delimiter closer) { | ||||
|         Delimiter delimiter = closer.previous; | ||||
|         while (delimiter != null && delimiter != opener) { | ||||
|             Delimiter previousDelimiter = delimiter.previous; | ||||
|             removeDelimiterKeepNode(delimiter); | ||||
|             delimiter = previousDelimiter; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Remove the delimiter and the corresponding text node. For used delimiters, e.g. `*` in `*foo*`. | ||||
|      */ | ||||
|     protected void removeDelimiterAndNode(Delimiter delim) { | ||||
|         Text node = delim.node; | ||||
|         node.unlink(); | ||||
|         removeDelimiter(delim); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Remove the delimiter but keep the corresponding node as text. For unused delimiters such as `_` in `foo_bar`. | ||||
|      */ | ||||
|     protected void removeDelimiterKeepNode(Delimiter delim) { | ||||
|         removeDelimiter(delim); | ||||
|     } | ||||
| 
 | ||||
|     protected void removeDelimiter(Delimiter delim) { | ||||
|         if (delim.previous != null) { | ||||
|             delim.previous.next = delim.next; | ||||
|         } | ||||
|         if (delim.next == null) { | ||||
|             // top of stack | ||||
| //            lastDelimiter = delim.previous; | ||||
|             context.lastDelimiter(delim.previous); | ||||
|         } else { | ||||
|             delim.next.previous = delim.previous; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,62 @@ | ||||
| package io.noties.markwon.sample.editor.inline; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.Delimiter; | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.parser.delimiter.DelimiterProcessor; | ||||
| 
 | ||||
| import java.util.Map; | ||||
| 
 | ||||
| public class InlineContext { | ||||
| 
 | ||||
|     /** | ||||
|      * Top delimiter (emphasis, strong emphasis or custom emphasis). (Brackets are on a separate stack, different | ||||
|      * from the algorithm described in the spec.) | ||||
|      */ | ||||
|     private Delimiter lastDelimiter; | ||||
| 
 | ||||
|     /** | ||||
|      * Top opening bracket (<code>[</code> or <code> Dimitry Ivanov
						Dimitry Ivanov