commit
						b844f4db6c
					
				
							
								
								
									
										2
									
								
								.github/workflows/develop.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/develop.yml
									
									
									
									
										vendored
									
									
								
							| @ -15,7 +15,7 @@ jobs: | ||||
|         with: | ||||
|           java-version: 1.8 | ||||
|       - name: Build with Gradle | ||||
|         run: ./gradlew build | ||||
|         run: ./gradlew build -Prelease | ||||
| 
 | ||||
|   deploy: | ||||
|     needs: build | ||||
|  | ||||
							
								
								
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @ -1,5 +1,24 @@ | ||||
| # Changelog | ||||
| 
 | ||||
| # 4.2.0 | ||||
| * `MarkwonEditor` to highlight markdown input whilst editing (new module: `markwon-editor`) | ||||
| * `CoilImagesPlugin` image loader based on [Coil] library (new module: `markwon-image-coil`) ([#166], [#174]) | ||||
| <br>Thanks to [@tylerbwong] | ||||
| * `MarkwonInlineParser` to customize inline parsing (new module: `markwon-inline-parser`) | ||||
| * Update commonmark-java to `0.13.0` (and commonmark spec `0.29`) | ||||
| * `Markwon#configuration` method to expose `MarkwonConfiguration` via public API | ||||
| * `HeadingSpan#getLevel` getter | ||||
| * Add `SvgPictureMediaDecoder` in `image` module to deal with SVG without dimensions ([#165]) | ||||
| * `LinkSpan#getLink` method | ||||
| * `LinkifyPlugin` applies link span that is configured by `Markwon` (obtain via span factory) | ||||
| * `LinkifyPlugin` is thread-safe | ||||
| 
 | ||||
| [@tylerbwong]: https://github.com/tylerbwong | ||||
| [Coil]: https://github.com/coil-kt/coil | ||||
| [#165]: https://github.com/noties/Markwon/issues/165 | ||||
| [#166]: https://github.com/noties/Markwon/issues/166 | ||||
| [#174]: https://github.com/noties/Markwon/pull/174 | ||||
| 
 | ||||
| # 4.1.2 | ||||
| * Do not re-use RenderProps when creating a new visitor (fixes [#171]) | ||||
| 
 | ||||
|  | ||||
| @ -19,6 +19,9 @@ features listed in [commonmark-spec] are supported | ||||
| (including support for **inlined/block HTML code**, **markdown tables**, | ||||
| **images** and **syntax highlight**). | ||||
| 
 | ||||
| Since version **4.2.0** **Markwon** comes with an [editor](./markwon-editor/) to _highlight_ markdown input | ||||
| as user types (for example in **EditText**). | ||||
| 
 | ||||
| [commonmark-spec]: https://spec.commonmark.org/0.28/ | ||||
| [commonmark-java]: https://github.com/atlassian/commonmark-java/blob/master/README.md | ||||
| 
 | ||||
|  | ||||
| @ -17,13 +17,6 @@ android { | ||||
|     lintOptions { | ||||
|         abortOnError false | ||||
|     } | ||||
| 
 | ||||
|     buildTypes { | ||||
|         debug { | ||||
|             minifyEnabled false | ||||
|             proguardFile 'proguard.pro' | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| dependencies { | ||||
|  | ||||
							
								
								
									
										19
									
								
								build.gradle
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								build.gradle
									
									
									
									
									
								
							| @ -4,7 +4,7 @@ buildscript { | ||||
|         jcenter() | ||||
|     } | ||||
|     dependencies { | ||||
|         classpath 'com.android.tools.build:gradle:3.5.1' | ||||
|         classpath 'com.android.tools.build:gradle:3.5.2' | ||||
|         classpath 'com.github.ben-manes:gradle-versions-plugin:0.21.0' | ||||
|     } | ||||
| } | ||||
| @ -44,7 +44,6 @@ if (hasProperty('local')) { | ||||
| 
 | ||||
| ext { | ||||
| 
 | ||||
|     // NB, updating build-tools or compile-sdk will require updating Travis config (.travis.yml) | ||||
|     config = [ | ||||
|             'build-tools'    : '28.0.3', | ||||
|             'compile-sdk'    : 28, | ||||
| @ -53,7 +52,7 @@ ext { | ||||
|             'push-aar-gradle': 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle' | ||||
|     ] | ||||
| 
 | ||||
|     final def commonMarkVersion = '0.12.1' | ||||
|     final def commonMarkVersion = '0.13.0' | ||||
|     final def daggerVersion = '2.10' | ||||
| 
 | ||||
|     deps = [ | ||||
| @ -72,7 +71,8 @@ ext { | ||||
|             'adapt'                   : 'io.noties:adapt:2.0.0', | ||||
|             'dagger'                  : "com.google.dagger:dagger:$daggerVersion", | ||||
|             'picasso'                 : 'com.squareup.picasso:picasso:2.71828', | ||||
|             'glide'                   : 'com.github.bumptech.glide:glide:4.9.0' | ||||
|             'glide'                   : 'com.github.bumptech.glide:glide:4.9.0', | ||||
|             'coil'                    : 'io.coil-kt:coil:0.8.0' | ||||
|     ] | ||||
| 
 | ||||
|     deps['annotationProcessor'] = [ | ||||
| @ -81,11 +81,12 @@ ext { | ||||
|     ] | ||||
| 
 | ||||
|     deps['test'] = [ | ||||
|             'junit'      : 'junit:junit:4.12', | ||||
|             'robolectric': 'org.robolectric:robolectric:3.8', | ||||
|             'ix-java'    : 'com.github.akarnokd:ixjava:1.0.0', | ||||
|             'commons-io' : 'commons-io:commons-io:2.6', | ||||
|             'mockito'    : 'org.mockito:mockito-core:2.21.0' | ||||
|             'junit'               : 'junit:junit:4.12', | ||||
|             'robolectric'         : 'org.robolectric:robolectric:3.8', | ||||
|             'ix-java'             : 'com.github.akarnokd:ixjava:1.0.0', | ||||
|             'commons-io'          : 'commons-io:commons-io:2.6', | ||||
|             'mockito'             : 'org.mockito:mockito-core:2.21.0', | ||||
|             'commonmark-test-util': "com.atlassian.commonmark:commonmark-test-util:$commonMarkVersion", | ||||
|     ] | ||||
| 
 | ||||
|     registerArtifact = this.®isterArtifact | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| 
 | ||||
| // this is a generated file, do not modify. To update it run 'collectArtifacts.js' script
 | ||||
| const artifacts = [{"id":"core","name":"Core","group":"io.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"ext-latex","name":"LaTeX","group":"io.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"io.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"io.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"io.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"io.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image","name":"Image","group":"io.noties.markwon","description":"Markwon image loading module (with optional GIF and SVG support)"},{"id":"image-glide","name":"Image Glide","group":"io.noties.markwon","description":"Markwon image loading module (based on Glide library)"},{"id":"image-picasso","name":"Image Picasso","group":"io.noties.markwon","description":"Markwon image loading module (based on Picasso library)"},{"id":"linkify","name":"Linkify","group":"io.noties.markwon","description":"Markwon plugin to linkify text (based on Android Linkify)"},{"id":"recycler","name":"Recycler","group":"io.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"recycler-table","name":"Recycler Table","group":"io.noties.markwon","description":"Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget"},{"id":"simple-ext","name":"Simple Extension","group":"io.noties.markwon","description":"Custom extension based on simple delimiter usage"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"io.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}]; | ||||
| const artifacts = [{"id":"core","name":"Core","group":"io.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"editor","name":"Editor","group":"io.noties.markwon","description":"Markdown editor based on Markwon"},{"id":"ext-latex","name":"LaTeX","group":"io.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"io.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"io.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"io.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"io.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image","name":"Image","group":"io.noties.markwon","description":"Markwon image loading module (with optional GIF and SVG support)"},{"id":"image-coil","name":"Image Coil","group":"io.noties.markwon","description":"Markwon image loading module (based on Coil library)"},{"id":"image-glide","name":"Image Glide","group":"io.noties.markwon","description":"Markwon image loading module (based on Glide library)"},{"id":"image-picasso","name":"Image Picasso","group":"io.noties.markwon","description":"Markwon image loading module (based on Picasso library)"},{"id":"inline-parser","name":"Inline Parser","group":"io.noties.markwon","description":"Markwon customizable commonmark-java InlineParser"},{"id":"linkify","name":"Linkify","group":"io.noties.markwon","description":"Markwon plugin to linkify text (based on Android Linkify)"},{"id":"recycler","name":"Recycler","group":"io.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"recycler-table","name":"Recycler Table","group":"io.noties.markwon","description":"Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget"},{"id":"simple-ext","name":"Simple Extension","group":"io.noties.markwon","description":"Custom extension based on simple delimiter usage"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"io.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}]; | ||||
| export { artifacts }; | ||||
|  | ||||
| @ -95,14 +95,17 @@ module.exports = { | ||||
|                         '/docs/v4/core/text-setter.md' | ||||
|                     ] | ||||
|                 }, | ||||
|                 '/docs/v4/editor/', | ||||
|                 '/docs/v4/ext-latex/', | ||||
|                 '/docs/v4/ext-strikethrough/', | ||||
|                 '/docs/v4/ext-tables/', | ||||
|                 '/docs/v4/ext-tasklist/', | ||||
|                 '/docs/v4/html/', | ||||
|                 '/docs/v4/image/', | ||||
|                 '/docs/v4/image-coil/', | ||||
|                 '/docs/v4/image-glide/', | ||||
|                 '/docs/v4/image-picasso/', | ||||
|                 '/docs/v4/inline-parser/', | ||||
|                 '/docs/v4/linkify/', | ||||
|                 '/docs/v4/recycler/', | ||||
|                 '/docs/v4/recycler-table/', | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								docs/.vuepress/public/assets/markwon-editor-preview.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/.vuepress/public/assets/markwon-editor-preview.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/.vuepress/public/assets/markwon-editor.mp4
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/.vuepress/public/assets/markwon-editor.mp4
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| @ -21,6 +21,11 @@ but also gives all the means to tweak the appearance if desired. All markdown fe | ||||
| listed in <Link name="commonmark-spec" /> are supported (including support for **inlined/block HTML code**,  | ||||
| **markdown tables**, **images** and **syntax highlight**). | ||||
| 
 | ||||
| Since version <Badge text="4.2.0" /> **Markwon** comes with an [editor] to _highlight_ markdown input | ||||
| as user types (for example in **EditText**). | ||||
| 
 | ||||
| [editor]: /docs/v4/editor/ | ||||
| 
 | ||||
| ## Supported markdown features | ||||
| 
 | ||||
| * Emphasis (`*`, `_`) | ||||
|  | ||||
							
								
								
									
										150
									
								
								docs/docs/v4/editor/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								docs/docs/v4/editor/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,150 @@ | ||||
| # Editor <Badge text="4.2.0" /> | ||||
| 
 | ||||
| <MavenBadge4 :artifact="'editor'" /> | ||||
| 
 | ||||
| Markdown editing highlight for Android based on **Markwon**. | ||||
| 
 | ||||
| <style> | ||||
| video { | ||||
|     max-height: 82vh; | ||||
| } | ||||
| </style> | ||||
| 
 | ||||
| <video controls="true" loop="" :poster="$withBase('/assets/markwon-editor-preview.jpg')"> | ||||
|     <source :src="$withBase('/assets/markwon-editor.mp4')" type="video/mp4"> | ||||
|     You browser does not support mp4 playback, try downloading video file  | ||||
|     <a :href="$withBase('/assets/markwon-editor.mp4')">directly</a> | ||||
| </video> | ||||
| 
 | ||||
| ## Getting started with editor | ||||
| 
 | ||||
| ```java | ||||
| // obtain Markwon instance | ||||
| final Markwon markwon = Markwon.create(this); | ||||
| 
 | ||||
| // create editor | ||||
| final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
| 
 | ||||
| // set edit listener | ||||
| editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
| ``` | ||||
| 
 | ||||
| The code above _highlights_ in-place which is OK for relatively small markdown inputs. | ||||
| If you wish to offload main thread and highlight in background use `withPreRender` | ||||
| `MarkwonEditorTextWatcher`: | ||||
| 
 | ||||
| ```java | ||||
| editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( | ||||
|         editor, | ||||
|         Executors.newCachedThreadPool(), | ||||
|         editText)); | ||||
| ``` | ||||
| 
 | ||||
| `MarkwonEditorTextWatcher` automatically triggers markdown highlight when text in `EditText` changes. | ||||
| But you still can invoke `MarkwonEditor` manually: | ||||
| 
 | ||||
| ```java | ||||
| editor.process(editText.getText()); | ||||
| 
 | ||||
| // please note that MarkwonEditor operates on caller thread, | ||||
| // if you wish to execute this operation in background - this method | ||||
| // must be called from background thread | ||||
| editor.preRender(editText.getText(), new MarkwonEditor.PreRenderResultListener() { | ||||
|     @Override | ||||
|     public void onPreRenderResult(@NonNull MarkwonEditor.PreRenderResult result) { | ||||
|         // it's wise to check if rendered result is for the same input, | ||||
|         // for example by matching raw input | ||||
|         if (editText.getText().toString().equals(result.resultEditable().toString())) { | ||||
|              | ||||
|             // if you are in background thread do not forget | ||||
|             // to execute dispatch in main thread | ||||
|             result.dispatchTo(editText.getText()); | ||||
|         } | ||||
|     } | ||||
| }); | ||||
| ``` | ||||
| 
 | ||||
| :::warning Implementation Detail | ||||
| It must be mentioned that highlight is implemented via text diff. Everything | ||||
| that is present in raw markdown input but missing from rendered result is considered | ||||
| to be _punctuation_. | ||||
| ::: | ||||
| 
 | ||||
| :::danger Tables and LaTeX | ||||
| Tables and LaTeX nodes won't be rendered correctly. They will be treated as _punctuation_ | ||||
| as whole. This comes from their implementation - they are _mocked_ and do not present | ||||
| in final result as text and thus cannot be _diffed_.  | ||||
| ::: | ||||
| 
 | ||||
| ## Custom punctuation span | ||||
| 
 | ||||
| By default `MarkwonEditor` uses lighter text color of widget to customize punctuation. | ||||
| If you wish to use a different span you can use `punctuationSpan` configuration step: | ||||
| 
 | ||||
| ```java | ||||
| final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||
|         .punctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new) | ||||
|         .build(); | ||||
| ``` | ||||
| 
 | ||||
| ```java | ||||
| public class CustomPunctuationSpan extends ForegroundColorSpan { | ||||
|     CustomPunctuationSpan() { | ||||
|         super(0xFFFF0000); // RED | ||||
|     } | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| ## Additional handling | ||||
| 
 | ||||
| In order to additionally highlight portions of markdown input (for example make text wrapped with `**` | ||||
| symbols **bold**) `EditHandler` can be used: | ||||
| 
 | ||||
| ```java | ||||
| 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 | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public Class<StrongEmphasisSpan> markdownSpanType() { | ||||
|                 return StrongEmphasisSpan.class; | ||||
|             } | ||||
|         }) | ||||
|         .build(); | ||||
| ``` | ||||
							
								
								
									
										35
									
								
								docs/docs/v4/image-coil/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								docs/docs/v4/image-coil/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| # Image Coil | ||||
| 
 | ||||
| <MavenBadge4 :artifact="'image-coil'" /> | ||||
| 
 | ||||
| Image loading based on `Coil` library | ||||
| 
 | ||||
| ```kotlin | ||||
| val markwon = Markwon.builder(context) | ||||
|         // automatically create Coil instance | ||||
|         .usePlugin(CoilImagesPlugin.create(context)) | ||||
|         // use supplied ImageLoader instance | ||||
|         .usePlugin(CoilImagesPlugin.create( | ||||
|             context, | ||||
|             ImageLoader(context) { | ||||
|                 availableMemoryPercentage(0.5) | ||||
|                 bitmapPoolPercentage(0.5) | ||||
|                 crossfade(true) | ||||
|             } | ||||
|         )) | ||||
|         // if you need more control | ||||
|         .usePlugin(CoilImagesPlugin.create(object : CoilImagesPlugin.CoilStore { | ||||
|             override fun load(drawable: AsyncDrawable): LoadRequest { | ||||
|                 return LoadRequest(context, customImageLoader.defaults) { | ||||
|                     data(drawable.destination) | ||||
|                     crossfade(true) | ||||
|                     transformations(CircleCropTransformation()) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             override cancel(disposable: RequestDisposable) { | ||||
|                 disposable.dispose() | ||||
|             } | ||||
|         }, customImageLoader)) | ||||
|         .build() | ||||
| ``` | ||||
							
								
								
									
										75
									
								
								docs/docs/v4/inline-parser/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								docs/docs/v4/inline-parser/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| # Inline Parser <Badge text="4.2.0" /> | ||||
| 
 | ||||
| **Experimental** commonmark-java inline parser that allows customizing  | ||||
| core features and/or extend with own.  | ||||
| 
 | ||||
| Usage of _internal_ classes: | ||||
| ```java | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.Delimiter; | ||||
| import org.commonmark.internal.util.Escaping; | ||||
| import org.commonmark.internal.util.Html5Entities; | ||||
| import org.commonmark.internal.util.LinkScanner; | ||||
| import org.commonmark.internal.util.Parsing; | ||||
| import org.commonmark.internal.inline.AsteriskDelimiterProcessor; | ||||
| import org.commonmark.internal.inline.UnderscoreDelimiterProcessor; | ||||
| ``` | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ```java | ||||
| // all default (like current commonmark-java InlineParserImpl)  | ||||
| final InlineParserFactory factory = MarkwonInlineParser.factoryBuilder() | ||||
|         .build(); | ||||
| ``` | ||||
| 
 | ||||
| ```java | ||||
| // disable images (current markdown images will be considered as links): | ||||
| final InlineParserFactory factory = MarkwonInlineParser.factoryBuilder() | ||||
|         .excludeInlineProcessor(BangInlineProcessor.class) | ||||
|         .build(); | ||||
| ``` | ||||
| 
 | ||||
| ```java | ||||
| // disable core delimiter processors for `*`|`_` and `**`|`__` | ||||
| final InlineParserFactory factory = MarkwonInlineParser.factoryBuilder() | ||||
|         .excludeDelimiterProcessor(AsteriskDelimiterProcessor.class) | ||||
|         .excludeDelimiterProcessor(UnderscoreDelimiterProcessor.class) | ||||
|         .build(); | ||||
| ``` | ||||
| 
 | ||||
| ```java | ||||
| // disable _all_ markdown inlines except for links (open and close bracket handling `[` & `]`) | ||||
| final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilderNoDefaults() | ||||
|         // note that there is no `includeDefaults` method call | ||||
|         .referencesEnabled(true) | ||||
|         .addInlineProcessor(new OpenBracketInlineProcessor()) | ||||
|         .addInlineProcessor(new CloseBracketInlineProcessor()) | ||||
|         .build(); | ||||
| ``` | ||||
| 
 | ||||
| To use custom InlineParser: | ||||
| ```java | ||||
| final Markwon markwon = Markwon.builder(this) | ||||
|         .usePlugin(new AbstractMarkwonPlugin() { | ||||
|             @Override | ||||
|             public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                 builder.inlineParserFactory(inlineParserFactory); | ||||
|             } | ||||
|         }) | ||||
|         .build(); | ||||
| ``` | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| The list of available inline processors: | ||||
| 
 | ||||
| * `AutolinkInlineProcessor` (`<` => `<me@mydoma.in>`) | ||||
| * `BackslashInlineProcessor` (`\\`) | ||||
| * `BackticksInlineProcessor` (<code>`</code> => <code>`code`</code>) | ||||
| * `BangInlineProcessor` (`!` => ``) | ||||
| * `CloseBracketInlineProcessor` (`]` => `[link](#href)`, ``) | ||||
| * `EntityInlineProcessor` (`&` => `&`) | ||||
| * `HtmlInlineProcessor` (`<` => `<html></html>`) | ||||
| * `NewLineInlineProcessor` (`\n`) | ||||
| * `OpenBracketInlineProcessor` (`[` => `[link](#href)`) | ||||
| @ -8,7 +8,7 @@ android.enableJetifier=true | ||||
| android.enableBuildCache=true | ||||
| android.buildCacheDir=build/pre-dex-cache | ||||
| 
 | ||||
| VERSION_NAME=4.1.2 | ||||
| VERSION_NAME=4.2.0 | ||||
| 
 | ||||
| GROUP=io.noties.markwon | ||||
| POM_DESCRIPTION=Markwon markdown for Android | ||||
|  | ||||
| @ -134,6 +134,9 @@ public abstract class Markwon { | ||||
|     @NonNull | ||||
|     public abstract List<? extends MarkwonPlugin> getPlugins(); | ||||
| 
 | ||||
|     @NonNull | ||||
|     public abstract MarkwonConfiguration configuration(); | ||||
| 
 | ||||
|     /** | ||||
|      * Interface to set text on a TextView. Primary goal is to give a way to use PrecomputedText | ||||
|      * functionality | ||||
| @ -141,21 +144,21 @@ public abstract class Markwon { | ||||
|      * @see PrecomputedTextSetterCompat | ||||
|      * @since 4.1.0 | ||||
|      */ | ||||
| public interface TextSetter { | ||||
|     /** | ||||
|      * @param textView   TextView | ||||
|      * @param markdown   prepared markdown | ||||
|      * @param bufferType BufferType specified when building {@link Markwon} instance | ||||
|      *                   via {@link Builder#bufferType(TextView.BufferType)} | ||||
|      * @param onComplete action to run when set-text is finished (required to call in order | ||||
|      *                   to execute {@link MarkwonPlugin#afterSetText(TextView)}) | ||||
|      */ | ||||
|     void setText( | ||||
|             @NonNull TextView textView, | ||||
|             @NonNull Spanned markdown, | ||||
|             @NonNull TextView.BufferType bufferType, | ||||
|             @NonNull Runnable onComplete); | ||||
| } | ||||
|     public interface TextSetter { | ||||
|         /** | ||||
|          * @param textView   TextView | ||||
|          * @param markdown   prepared markdown | ||||
|          * @param bufferType BufferType specified when building {@link Markwon} instance | ||||
|          *                   via {@link Builder#bufferType(TextView.BufferType)} | ||||
|          * @param onComplete action to run when set-text is finished (required to call in order | ||||
|          *                   to execute {@link MarkwonPlugin#afterSetText(TextView)}) | ||||
|          */ | ||||
|         void setText( | ||||
|                 @NonNull TextView textView, | ||||
|                 @NonNull Spanned markdown, | ||||
|                 @NonNull TextView.BufferType bufferType, | ||||
|                 @NonNull Runnable onComplete); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Builder for {@link Markwon}. | ||||
|  | ||||
| @ -113,6 +113,7 @@ class MarkwonBuilderImpl implements Markwon.Builder { | ||||
|                 textSetter, | ||||
|                 parserBuilder.build(), | ||||
|                 visitorFactory, | ||||
|                 configuration, | ||||
|                 Collections.unmodifiableList(plugins) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
| @ -21,6 +21,7 @@ class MarkwonImpl extends Markwon { | ||||
|     private final TextView.BufferType bufferType; | ||||
|     private final Parser parser; | ||||
|     private final MarkwonVisitorFactory visitorFactory; // @since 4.1.1 | ||||
|     private final MarkwonConfiguration configuration; | ||||
|     private final List<MarkwonPlugin> plugins; | ||||
| 
 | ||||
|     // @since 4.1.0 | ||||
| @ -32,11 +33,13 @@ class MarkwonImpl extends Markwon { | ||||
|             @Nullable TextSetter textSetter, | ||||
|             @NonNull Parser parser, | ||||
|             @NonNull MarkwonVisitorFactory visitorFactory, | ||||
|             @NonNull MarkwonConfiguration configuration, | ||||
|             @NonNull List<MarkwonPlugin> plugins) { | ||||
|         this.bufferType = bufferType; | ||||
|         this.textSetter = textSetter; | ||||
|         this.parser = parser; | ||||
|         this.visitorFactory = visitorFactory; | ||||
|         this.configuration = configuration; | ||||
|         this.plugins = plugins; | ||||
|     } | ||||
| 
 | ||||
| @ -154,4 +157,10 @@ class MarkwonImpl extends Markwon { | ||||
|     public List<? extends MarkwonPlugin> getPlugins() { | ||||
|         return Collections.unmodifiableList(plugins); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public MarkwonConfiguration configuration() { | ||||
|         return configuration; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -18,6 +18,7 @@ import org.commonmark.node.HtmlInline; | ||||
| import org.commonmark.node.Image; | ||||
| import org.commonmark.node.IndentedCodeBlock; | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.LinkReferenceDefinition; | ||||
| import org.commonmark.node.ListItem; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.OrderedList; | ||||
| @ -155,6 +156,11 @@ class MarkwonVisitorImpl implements MarkwonVisitor { | ||||
|         visit((Node) text); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void visit(LinkReferenceDefinition linkReferenceDefinition) { | ||||
|         visit((Node) linkReferenceDefinition); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void visit(CustomBlock customBlock) { | ||||
|         visit((Node) customBlock); | ||||
|  | ||||
| @ -77,4 +77,11 @@ public class HeadingSpan extends MetricAffectingSpan implements LeadingMarginSpa | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @since 4.2.0 | ||||
|      */ | ||||
|     public int getLevel() { | ||||
|         return level; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -31,7 +31,15 @@ public class LinkSpan extends URLSpan { | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void updateDrawState(TextPaint ds) { | ||||
|     public void updateDrawState(@NonNull TextPaint ds) { | ||||
|         theme.applyLinkStyle(ds); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @since 4.2.0 | ||||
|      */ | ||||
|     @NonNull | ||||
|     public String getLink() { | ||||
|         return link; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -49,6 +49,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
| 
 | ||||
|         impl.parse("whatever"); | ||||
| @ -72,6 +73,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 parser, | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Arrays.asList(first, second)); | ||||
| 
 | ||||
|         impl.parse("zero"); | ||||
| @ -99,6 +101,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 visitorFactory, | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
| 
 | ||||
|         when(visitorFactory.create()).thenReturn(visitor); | ||||
| @ -145,6 +148,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 visitorFactory, | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.<MarkwonPlugin>emptyList()); | ||||
| 
 | ||||
|         impl.render(mock(Node.class)); | ||||
| @ -180,6 +184,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 visitorFactory, | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
| 
 | ||||
|         final AtomicBoolean flag = new AtomicBoolean(false); | ||||
| @ -218,6 +223,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
| 
 | ||||
|         final TextView textView = mock(TextView.class); | ||||
| @ -265,6 +271,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 plugins); | ||||
| 
 | ||||
|         assertTrue("First", impl.hasPlugin(First.class)); | ||||
| @ -287,6 +294,7 @@ public class MarkwonImplTest { | ||||
|                 textSetter, | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
| 
 | ||||
|         final TextView textView = mock(TextView.class); | ||||
| @ -330,6 +338,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 plugins); | ||||
| 
 | ||||
|         // should be returned | ||||
| @ -360,6 +369,7 @@ public class MarkwonImplTest { | ||||
|                 null, | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 plugins); | ||||
| 
 | ||||
|         final List<? extends MarkwonPlugin> list = impl.getPlugins(); | ||||
|  | ||||
| @ -50,6 +50,23 @@ public class OrderedListTest extends BaseSuiteTest { | ||||
|   @Test | ||||
|   public void two_spaces() { | ||||
|     // just a regular flat-list (no sub-lists) | ||||
|     // UPD: cannot have more than 3 spaces (0.29), now it is: | ||||
|     // 1. First | ||||
|     // 2. Second 3. Third | ||||
| 
 | ||||
| //    final Document document = document( | ||||
| //      span(ORDERED_LIST, | ||||
| //        args("start", 1), | ||||
| //        text("First")), | ||||
| //      text("\n"), | ||||
| //      span(ORDERED_LIST, | ||||
| //        args("start", 2), | ||||
| //        text("Second")), | ||||
| //      text("\n"), | ||||
| //      span(ORDERED_LIST, | ||||
| //        args("start", 3), | ||||
| //        text("Third")) | ||||
| //    ); | ||||
| 
 | ||||
|     final Document document = document( | ||||
|       span(ORDERED_LIST, | ||||
| @ -58,11 +75,7 @@ public class OrderedListTest extends BaseSuiteTest { | ||||
|       text("\n"), | ||||
|       span(ORDERED_LIST, | ||||
|         args("start", 2), | ||||
|         text("Second")), | ||||
|       text("\n"), | ||||
|       span(ORDERED_LIST, | ||||
|         args("start", 3), | ||||
|         text("Third")) | ||||
|         text("Second 3. Third")) | ||||
|     ); | ||||
| 
 | ||||
|     matchInput("ol-2-spaces.md", document); | ||||
|  | ||||
							
								
								
									
										16
									
								
								markwon-editor/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								markwon-editor/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| # Editor | ||||
| 
 | ||||
| Markdown editor for Android based on `Markwon`. | ||||
| 
 | ||||
| Main principle: _difference_ between input text and rendered markdown is considered to be | ||||
| _punctuation_. | ||||
| 
 | ||||
| 
 | ||||
| [https://noties.io/Markwon/docs/v4/editor/](https://noties.io/Markwon/docs/v4/editor/) | ||||
| 
 | ||||
| 
 | ||||
| ## Limitations | ||||
| 
 | ||||
| Tables and LaTeX nodes won't be rendered correctly. They will be treated as _punctuation_ | ||||
| as whole. This comes from their implementation - they are _mocked_ and do not present | ||||
| in final result as text and thus cannot be _diffed_.  | ||||
							
								
								
									
										32
									
								
								markwon-editor/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								markwon-editor/build.gradle
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | ||||
| apply plugin: 'com.android.library' | ||||
| 
 | ||||
| android { | ||||
| 
 | ||||
|     compileSdkVersion config['compile-sdk'] | ||||
|     buildToolsVersion config['build-tools'] | ||||
| 
 | ||||
|     defaultConfig { | ||||
|         minSdkVersion config['min-sdk'] | ||||
|         targetSdkVersion config['target-sdk'] | ||||
|         versionCode 1 | ||||
|         versionName version | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| dependencies { | ||||
| 
 | ||||
|     api project(':markwon-core') | ||||
| 
 | ||||
|     deps['test'].with { | ||||
| 
 | ||||
|         testImplementation project(':markwon-test-span') | ||||
| 
 | ||||
|         testImplementation it['junit'] | ||||
|         testImplementation it['robolectric'] | ||||
|         testImplementation it['mockito'] | ||||
| 
 | ||||
|         testImplementation it['commons-io'] | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| registerArtifact(this) | ||||
							
								
								
									
										4
									
								
								markwon-editor/gradle.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								markwon-editor/gradle.properties
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| POM_NAME=Editor | ||||
| POM_ARTIFACT_ID=editor | ||||
| POM_DESCRIPTION=Markdown editor based on Markwon | ||||
| POM_PACKAGING=aar | ||||
							
								
								
									
										1
									
								
								markwon-editor/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								markwon-editor/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <manifest package="io.noties.markwon.editor" /> | ||||
| @ -0,0 +1,18 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import io.noties.markwon.Markwon; | ||||
| 
 | ||||
| /** | ||||
|  * @see EditHandler | ||||
|  * @see io.noties.markwon.editor.handler.EmphasisEditHandler | ||||
|  * @see io.noties.markwon.editor.handler.StrongEmphasisEditHandler | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public abstract class AbstractEditHandler<T> implements EditHandler<T> { | ||||
|     @Override | ||||
|     public void init(@NonNull Markwon markwon) { | ||||
| 
 | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,47 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.editor.handler.EmphasisEditHandler; | ||||
| import io.noties.markwon.editor.handler.StrongEmphasisEditHandler; | ||||
| 
 | ||||
| /** | ||||
|  * @see EmphasisEditHandler | ||||
|  * @see StrongEmphasisEditHandler | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public interface EditHandler<T> { | ||||
| 
 | ||||
|     void init(@NonNull Markwon markwon); | ||||
| 
 | ||||
|     void configurePersistedSpans(@NonNull PersistedSpans.Builder builder); | ||||
| 
 | ||||
|     // span is present only in off-screen rendered markdown, it must be processed and | ||||
|     //  a NEW one must be added to editable (via edit-persist-spans) | ||||
|     // | ||||
|     // NB, editable.setSpan must obtain span from `spans` and must be configured beforehand | ||||
|     // multiple spans are OK as long as they are configured | ||||
| 
 | ||||
|     /** | ||||
|      * @param persistedSpans | ||||
|      * @param editable | ||||
|      * @param input | ||||
|      * @param span | ||||
|      * @param spanStart | ||||
|      * @param spanTextLength | ||||
|      * @see MarkwonEditorUtils | ||||
|      */ | ||||
|     void handleMarkdownSpan( | ||||
|             @NonNull PersistedSpans persistedSpans, | ||||
|             @NonNull Editable editable, | ||||
|             @NonNull String input, | ||||
|             @NonNull T span, | ||||
|             int spanStart, | ||||
|             int spanTextLength); | ||||
| 
 | ||||
|     @NonNull | ||||
|     Class<T> markdownSpanType(); | ||||
| } | ||||
| @ -0,0 +1,189 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import java.util.HashMap; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| import io.noties.markwon.Markwon; | ||||
| 
 | ||||
| /** | ||||
|  * @see #builder(Markwon) | ||||
|  * @see #create(Markwon) | ||||
|  * @see #process(Editable) | ||||
|  * @see #preRender(Editable, PreRenderResultListener) | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public abstract class MarkwonEditor { | ||||
| 
 | ||||
|     /** | ||||
|      * @see #preRender(Editable, PreRenderResultListener) | ||||
|      */ | ||||
|     public interface PreRenderResult { | ||||
| 
 | ||||
|         /** | ||||
|          * @return Editable instance for which result was calculated. This must not be | ||||
|          * actual Editable of EditText | ||||
|          */ | ||||
|         @NonNull | ||||
|         Editable resultEditable(); | ||||
| 
 | ||||
|         /** | ||||
|          * Dispatch pre-rendering result to EditText | ||||
|          * | ||||
|          * @param editable to dispatch result to | ||||
|          */ | ||||
|         void dispatchTo(@NonNull Editable editable); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @see #preRender(Editable, PreRenderResultListener) | ||||
|      */ | ||||
|     public interface PreRenderResultListener { | ||||
|         void onPreRenderResult(@NonNull PreRenderResult result); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates default instance of {@link MarkwonEditor}. By default it will handle only | ||||
|      * punctuation spans (highlight markdown punctuation and nothing more). | ||||
|      * | ||||
|      * @see #builder(Markwon) | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static MarkwonEditor create(@NonNull Markwon markwon) { | ||||
|         return builder(markwon).build(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @see #create(Markwon) | ||||
|      * @see Builder | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static Builder builder(@NonNull Markwon markwon) { | ||||
|         return new Builder(markwon); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Synchronous method that processes supplied Editable in-place. If you wish to move this job | ||||
|      * to another thread consider using {@link #preRender(Editable, PreRenderResultListener)} | ||||
|      * | ||||
|      * @param editable to process | ||||
|      * @see #preRender(Editable, PreRenderResultListener) | ||||
|      */ | ||||
|     public abstract void process(@NonNull Editable editable); | ||||
| 
 | ||||
|     /** | ||||
|      * Pre-render highlight result. Can be useful to create highlight information on a different | ||||
|      * thread. | ||||
|      * <p> | ||||
|      * Please note that currently only `setSpan` and `removeSpan` actions will be recorded (and thus dispatched). | ||||
|      * Make sure you use only these methods in your {@link EditHandler}, or implement the required | ||||
|      * functionality some other way. | ||||
|      * | ||||
|      * @param editable          to process and pre-render | ||||
|      * @param preRenderListener listener to be notified when pre-render result will be ready | ||||
|      * @see #process(Editable) | ||||
|      */ | ||||
|     public abstract void preRender(@NonNull Editable editable, @NonNull PreRenderResultListener preRenderListener); | ||||
| 
 | ||||
| 
 | ||||
|     public static class Builder { | ||||
| 
 | ||||
|         private final Markwon markwon; | ||||
|         private final PersistedSpans.Provider persistedSpansProvider = PersistedSpans.provider(); | ||||
|         private final Map<Class<?>, EditHandler> editHandlers = new HashMap<>(0); | ||||
| 
 | ||||
|         private Class<?> punctuationSpanType; | ||||
| 
 | ||||
|         Builder(@NonNull Markwon markwon) { | ||||
|             this.markwon = markwon; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         public <T> Builder useEditHandler(@NonNull EditHandler<T> handler) { | ||||
|             this.editHandlers.put(handler.markdownSpanType(), handler); | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         /** | ||||
|          * Specify which punctuation span will be used. | ||||
|          * | ||||
|          * @param type    of the span | ||||
|          * @param factory to create a new instance of the span | ||||
|          */ | ||||
|         @NonNull | ||||
|         public <T> Builder punctuationSpan(@NonNull Class<T> type, @NonNull PersistedSpans.SpanFactory<T> factory) { | ||||
|             this.punctuationSpanType = type; | ||||
|             this.persistedSpansProvider.persistSpan(type, factory); | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         public MarkwonEditor build() { | ||||
| 
 | ||||
|             Class<?> punctuationSpanType = this.punctuationSpanType; | ||||
|             if (punctuationSpanType == null) { | ||||
|                 punctuationSpan(PunctuationSpan.class, new PersistedSpans.SpanFactory<PunctuationSpan>() { | ||||
|                     @NonNull | ||||
|                     @Override | ||||
|                     public PunctuationSpan create() { | ||||
|                         return new PunctuationSpan(); | ||||
|                     } | ||||
|                 }); | ||||
|                 punctuationSpanType = this.punctuationSpanType; | ||||
|             } | ||||
| 
 | ||||
|             for (EditHandler handler : editHandlers.values()) { | ||||
|                 handler.init(markwon); | ||||
|                 handler.configurePersistedSpans(persistedSpansProvider); | ||||
|             } | ||||
| 
 | ||||
|             final SpansHandler spansHandler = editHandlers.size() == 0 | ||||
|                     ? null | ||||
|                     : new SpansHandlerImpl(editHandlers); | ||||
| 
 | ||||
|             return new MarkwonEditorImpl( | ||||
|                     markwon, | ||||
|                     persistedSpansProvider, | ||||
|                     punctuationSpanType, | ||||
|                     spansHandler); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     interface SpansHandler { | ||||
|         void handle( | ||||
|                 @NonNull PersistedSpans spans, | ||||
|                 @NonNull Editable editable, | ||||
|                 @NonNull String input, | ||||
|                 @NonNull Object span, | ||||
|                 int spanStart, | ||||
|                 int spanTextLength); | ||||
|     } | ||||
| 
 | ||||
|     static class SpansHandlerImpl implements SpansHandler { | ||||
| 
 | ||||
|         private final Map<Class<?>, EditHandler> spanHandlers; | ||||
| 
 | ||||
|         SpansHandlerImpl(@NonNull Map<Class<?>, EditHandler> spanHandlers) { | ||||
|             this.spanHandlers = spanHandlers; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void handle( | ||||
|                 @NonNull PersistedSpans spans, | ||||
|                 @NonNull Editable editable, | ||||
|                 @NonNull String input, | ||||
|                 @NonNull Object span, | ||||
|                 int spanStart, | ||||
|                 int spanTextLength) { | ||||
|             final EditHandler handler = spanHandlers.get(span.getClass()); | ||||
|             if (handler != null) { | ||||
|                 //noinspection unchecked | ||||
|                 handler.handleMarkdownSpan(spans, editable, input, span, spanStart, spanTextLength); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,213 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.text.Spannable; | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.Spanned; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| 
 | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.editor.diff_match_patch.Diff; | ||||
| 
 | ||||
| class MarkwonEditorImpl extends MarkwonEditor { | ||||
| 
 | ||||
|     private final Markwon markwon; | ||||
|     private final PersistedSpans.Provider persistedSpansProvider; | ||||
|     private final Class<?> punctuationSpanType; | ||||
| 
 | ||||
|     @Nullable | ||||
|     private final SpansHandler spansHandler; | ||||
| 
 | ||||
|     MarkwonEditorImpl( | ||||
|             @NonNull Markwon markwon, | ||||
|             @NonNull PersistedSpans.Provider persistedSpansProvider, | ||||
|             @NonNull Class<?> punctuationSpanType, | ||||
|             @Nullable SpansHandler spansHandler) { | ||||
|         this.markwon = markwon; | ||||
|         this.persistedSpansProvider = persistedSpansProvider; | ||||
|         this.punctuationSpanType = punctuationSpanType; | ||||
|         this.spansHandler = spansHandler; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void process(@NonNull Editable editable) { | ||||
| 
 | ||||
|         final String input = editable.toString(); | ||||
| 
 | ||||
|         // NB, we cast to Spannable here without prior checks | ||||
|         //  if by some occasion Markwon stops returning here a Spannable our tests will catch that | ||||
|         //  (we need Spannable in order to remove processed spans, so they do not appear multiple times) | ||||
|         final Spannable renderedMarkdown = (Spannable) markwon.toMarkdown(input); | ||||
| 
 | ||||
|         final String markdown = renderedMarkdown.toString(); | ||||
| 
 | ||||
|         final SpansHandler spansHandler = this.spansHandler; | ||||
|         final boolean hasAdditionalSpans = spansHandler != null; | ||||
| 
 | ||||
|         final PersistedSpans persistedSpans = persistedSpansProvider.provide(editable); | ||||
|         try { | ||||
| 
 | ||||
|             final List<Diff> diffs = diff_match_patch.diff_main(input, markdown); | ||||
| 
 | ||||
|             int inputLength = 0; | ||||
|             int markdownLength = 0; | ||||
| 
 | ||||
|             for (Diff diff : diffs) { | ||||
| 
 | ||||
|                 switch (diff.operation) { | ||||
| 
 | ||||
|                     case DELETE: | ||||
| 
 | ||||
|                         final int start = inputLength; | ||||
|                         inputLength += diff.text.length(); | ||||
| 
 | ||||
|                         editable.setSpan( | ||||
|                                 persistedSpans.get(punctuationSpanType), | ||||
|                                 start, | ||||
|                                 inputLength, | ||||
|                                 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ||||
|                         ); | ||||
| 
 | ||||
|                         if (hasAdditionalSpans) { | ||||
|                             // obtain spans for a single character of renderedMarkdown | ||||
|                             //  editable here should return all spans that are contained in specified | ||||
|                             //  region. Later we match if span starts at current position | ||||
|                             //  and notify additional span handler about it | ||||
|                             final Object[] spans = renderedMarkdown.getSpans(markdownLength, markdownLength + 1, Object.class); | ||||
|                             for (Object span : spans) { | ||||
|                                 if (markdownLength == renderedMarkdown.getSpanStart(span)) { | ||||
| 
 | ||||
|                                     spansHandler.handle( | ||||
|                                             persistedSpans, | ||||
|                                             editable, | ||||
|                                             input, | ||||
|                                             span, | ||||
|                                             start, | ||||
|                                             renderedMarkdown.getSpanEnd(span) - markdownLength); | ||||
|                                     // NB, we do not break here in case of SpanFactory | ||||
|                                     // returns multiple spans for a markdown node, this way | ||||
|                                     // we will handle all of them | ||||
| 
 | ||||
|                                     // It is important to remove span after we have processed it | ||||
|                                     //  as we process them in 2 places: here and in EQUAL | ||||
|                                     renderedMarkdown.removeSpan(span); | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         break; | ||||
| 
 | ||||
|                     case INSERT: | ||||
|                         // no special handling here, but still we must advance the markdownLength | ||||
|                         markdownLength += diff.text.length(); | ||||
|                         break; | ||||
| 
 | ||||
|                     case EQUAL: | ||||
|                         final int length = diff.text.length(); | ||||
|                         final int inputStart = inputLength; | ||||
|                         final int markdownStart = markdownLength; | ||||
|                         inputLength += length; | ||||
|                         markdownLength += length; | ||||
| 
 | ||||
|                         // it is possible that there are spans for the text that is the same | ||||
|                         //  for example, if some links were _autolinked_ (text is the same, | ||||
|                         //  but there is an additional URLSpan) | ||||
|                         if (hasAdditionalSpans) { | ||||
|                             final Object[] spans = renderedMarkdown.getSpans(markdownStart, markdownLength, Object.class); | ||||
|                             for (Object span : spans) { | ||||
|                                 final int spanStart = renderedMarkdown.getSpanStart(span); | ||||
|                                 if (spanStart >= markdownStart) { | ||||
|                                     final int end = renderedMarkdown.getSpanEnd(span); | ||||
|                                     if (end <= markdownLength) { | ||||
| 
 | ||||
|                                         spansHandler.handle( | ||||
|                                                 persistedSpans, | ||||
|                                                 editable, | ||||
|                                                 input, | ||||
|                                                 span, | ||||
|                                                 // shift span to input position (can be different from the text itself) | ||||
|                                                 inputStart + (spanStart - markdownStart), | ||||
|                                                 end - spanStart | ||||
|                                         ); | ||||
| 
 | ||||
|                                         renderedMarkdown.removeSpan(span); | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         break; | ||||
| 
 | ||||
|                     default: | ||||
|                         throw new IllegalStateException(); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|         } finally { | ||||
|             persistedSpans.removeUnused(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void preRender(@NonNull final Editable editable, @NonNull PreRenderResultListener listener) { | ||||
|         final RecordingSpannableStringBuilder builder = new RecordingSpannableStringBuilder(editable); | ||||
|         process(builder); | ||||
|         listener.onPreRenderResult(new PreRenderResult() { | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public Editable resultEditable() { | ||||
|                 // if they are the same, they should be equals then (what about additional spans?? like cursor? it should not interfere....) | ||||
|                 return builder; | ||||
|             } | ||||
| 
 | ||||
|             @Override | ||||
|             public void dispatchTo(@NonNull Editable e) { | ||||
|                 for (Span span : builder.applied) { | ||||
|                     e.setSpan(span.what, span.start, span.end, span.flags); | ||||
|                 } | ||||
|                 for (Object span : builder.removed) { | ||||
|                     e.removeSpan(span); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private static class Span { | ||||
|         final Object what; | ||||
|         final int start; | ||||
|         final int end; | ||||
|         final int flags; | ||||
| 
 | ||||
|         Span(Object what, int start, int end, int flags) { | ||||
|             this.what = what; | ||||
|             this.start = start; | ||||
|             this.end = end; | ||||
|             this.flags = flags; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static class RecordingSpannableStringBuilder extends SpannableStringBuilder { | ||||
| 
 | ||||
|         final List<Span> applied = new ArrayList<>(3); | ||||
|         final List<Object> removed = new ArrayList<>(0); | ||||
| 
 | ||||
|         RecordingSpannableStringBuilder(CharSequence text) { | ||||
|             super(text); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void setSpan(Object what, int start, int end, int flags) { | ||||
|             super.setSpan(what, start, end, flags); | ||||
|             applied.add(new Span(what, start, end, flags)); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void removeSpan(Object what) { | ||||
|             super.removeSpan(what); | ||||
|             removed.add(what); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,177 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.TextWatcher; | ||||
| import android.view.View; | ||||
| import android.widget.EditText; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.util.concurrent.ExecutorService; | ||||
| import java.util.concurrent.Future; | ||||
| 
 | ||||
| /** | ||||
|  * Implementation of TextWatcher that uses {@link MarkwonEditor#process(Editable)} method | ||||
|  * to apply markdown highlighting right after text changes. | ||||
|  * | ||||
|  * @see MarkwonEditor#process(Editable) | ||||
|  * @see MarkwonEditor#preRender(Editable, MarkwonEditor.PreRenderResultListener) | ||||
|  * @see #withProcess(MarkwonEditor) | ||||
|  * @see #withPreRender(MarkwonEditor, ExecutorService, EditText) | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public abstract class MarkwonEditorTextWatcher implements TextWatcher { | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static MarkwonEditorTextWatcher withProcess(@NonNull MarkwonEditor editor) { | ||||
|         return new WithProcess(editor); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static MarkwonEditorTextWatcher withPreRender( | ||||
|             @NonNull MarkwonEditor editor, | ||||
|             @NonNull ExecutorService executorService, | ||||
|             @NonNull EditText editText) { | ||||
|         return new WithPreRender(editor, executorService, editText); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public abstract void afterTextChanged(Editable s); | ||||
| 
 | ||||
|     @Override | ||||
|     public void beforeTextChanged(CharSequence s, int start, int count, int after) { | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onTextChanged(CharSequence s, int start, int before, int count) { | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     static class WithProcess extends MarkwonEditorTextWatcher { | ||||
| 
 | ||||
|         private final MarkwonEditor editor; | ||||
| 
 | ||||
|         private boolean selfChange; | ||||
| 
 | ||||
|         WithProcess(@NonNull MarkwonEditor editor) { | ||||
|             this.editor = editor; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void afterTextChanged(Editable s) { | ||||
| 
 | ||||
|             if (selfChange) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             selfChange = true; | ||||
|             try { | ||||
|                 editor.process(s); | ||||
|             } finally { | ||||
|                 selfChange = false; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     static class WithPreRender extends MarkwonEditorTextWatcher { | ||||
| 
 | ||||
|         private final MarkwonEditor editor; | ||||
|         private final ExecutorService executorService; | ||||
| 
 | ||||
|         // As we operate on a single thread (main) we are fine with a regular int | ||||
|         //  for marking current _generation_ | ||||
|         private int generator; | ||||
| 
 | ||||
|         @Nullable | ||||
|         private EditText editText; | ||||
| 
 | ||||
|         private Future<?> future; | ||||
| 
 | ||||
|         private boolean selfChange; | ||||
| 
 | ||||
|         WithPreRender( | ||||
|                 @NonNull MarkwonEditor editor, | ||||
|                 @NonNull ExecutorService executorService, | ||||
|                 @NonNull EditText editText) { | ||||
|             this.editor = editor; | ||||
|             this.executorService = executorService; | ||||
|             this.editText = editText; | ||||
|             this.editText.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { | ||||
|                 @Override | ||||
|                 public void onViewAttachedToWindow(View v) { | ||||
| 
 | ||||
|                 } | ||||
| 
 | ||||
|                 @Override | ||||
|                 public void onViewDetachedFromWindow(View v) { | ||||
|                     WithPreRender.this.editText = null; | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void afterTextChanged(Editable s) { | ||||
| 
 | ||||
|             if (selfChange) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // both will be the same here (generator incremented and key assigned incremented value) | ||||
|             final int key = ++this.generator; | ||||
| 
 | ||||
|             if (future != null) { | ||||
|                 future.cancel(true); | ||||
|             } | ||||
| 
 | ||||
|             // copy current content (it's not good to pass EditText editable to other thread) | ||||
|             final SpannableStringBuilder builder = new SpannableStringBuilder(s); | ||||
| 
 | ||||
|             future = executorService.submit(new Runnable() { | ||||
|                 @Override | ||||
|                 public void run() { | ||||
|                     try { | ||||
|                         editor.preRender(builder, new MarkwonEditor.PreRenderResultListener() { | ||||
|                             @Override | ||||
|                             public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) { | ||||
|                                 final EditText et = editText; | ||||
|                                 if (et != null) { | ||||
|                                     et.post(new Runnable() { | ||||
|                                         @Override | ||||
|                                         public void run() { | ||||
|                                             if (key == generator) { | ||||
|                                                 final EditText et = editText; | ||||
|                                                 if (et != null) { | ||||
|                                                     selfChange = true; | ||||
|                                                     try { | ||||
|                                                         result.dispatchTo(editText.getText()); | ||||
|                                                     } finally { | ||||
|                                                         selfChange = false; | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         } | ||||
|                                     }); | ||||
|                                 } | ||||
|                             } | ||||
|                         }); | ||||
|                     } catch (final Throwable t) { | ||||
|                         final EditText et = editText; | ||||
|                         if (et != null) { | ||||
|                             // propagate exception to main thread | ||||
|                             et.post(new Runnable() { | ||||
|                                 @Override | ||||
|                                 public void run() { | ||||
|                                     throw new RuntimeException(t); | ||||
|                                 } | ||||
|                             }); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,183 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.Spanned; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| import java.util.Collection; | ||||
| import java.util.HashMap; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public abstract class MarkwonEditorUtils { | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static Map<Class<?>, List<Object>> extractSpans(@NonNull Spanned spanned, @NonNull Collection<Class<?>> types) { | ||||
| 
 | ||||
|         final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); | ||||
|         final Map<Class<?>, List<Object>> map = new HashMap<>(3); | ||||
| 
 | ||||
|         Class<?> type; | ||||
| 
 | ||||
|         for (Object span : spans) { | ||||
|             type = span.getClass(); | ||||
|             if (types.contains(type)) { | ||||
|                 List<Object> list = map.get(type); | ||||
|                 if (list == null) { | ||||
|                     list = new ArrayList<>(3); | ||||
|                     map.put(type, list); | ||||
|                 } | ||||
|                 list.add(span); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return map; | ||||
|     } | ||||
| 
 | ||||
|     public interface Match { | ||||
| 
 | ||||
|         @NonNull | ||||
|         String delimiter(); | ||||
| 
 | ||||
|         int start(); | ||||
| 
 | ||||
|         int end(); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static Match findDelimited(@NonNull String input, int startFrom, @NonNull String delimiter) { | ||||
|         final int start = input.indexOf(delimiter, startFrom); | ||||
|         if (start > -1) { | ||||
|             final int length = delimiter.length(); | ||||
|             final int end = input.indexOf(delimiter, start + length); | ||||
|             if (end > -1) { | ||||
|                 return new MatchImpl(delimiter, start, end + length); | ||||
|             } | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static Match findDelimited( | ||||
|             @NonNull String input, | ||||
|             int start, | ||||
|             @NonNull String delimiter1, | ||||
|             @NonNull String delimiter2) { | ||||
| 
 | ||||
|         final int l1 = delimiter1.length(); | ||||
|         final int l2 = delimiter2.length(); | ||||
| 
 | ||||
|         final char c1 = delimiter1.charAt(0); | ||||
|         final char c2 = delimiter2.charAt(0); | ||||
| 
 | ||||
|         char c; | ||||
|         char previousC = 0; | ||||
| 
 | ||||
|         Match match; | ||||
| 
 | ||||
|         for (int i = start, length = input.length(); i < length; i++) { | ||||
|             c = input.charAt(i); | ||||
| 
 | ||||
|             // if this char is the same as previous (and we obviously have no match) -> skip | ||||
|             if (c == previousC) { | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
|             if (c == c1) { | ||||
|                 match = matchDelimiter(input, i, length, delimiter1, l1); | ||||
|                 if (match != null) { | ||||
|                     return match; | ||||
|                 } | ||||
|             } else if (c == c2) { | ||||
|                 match = matchDelimiter(input, i, length, delimiter2, l2); | ||||
|                 if (match != null) { | ||||
|                     return match; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             previousC = c; | ||||
|         } | ||||
| 
 | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     // This method assumes that first char is matched already | ||||
|     @Nullable | ||||
|     private static Match matchDelimiter( | ||||
|             @NonNull String input, | ||||
|             int start, | ||||
|             int length, | ||||
|             @NonNull String delimiter, | ||||
|             int delimiterLength) { | ||||
| 
 | ||||
|         if (start + delimiterLength < length) { | ||||
| 
 | ||||
|             boolean result = true; | ||||
| 
 | ||||
|             for (int i = 1; i < delimiterLength; i++) { | ||||
|                 if (input.charAt(start + i) != delimiter.charAt(i)) { | ||||
|                     result = false; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (result) { | ||||
|                 // find end | ||||
|                 final int end = input.indexOf(delimiter, start + delimiterLength); | ||||
|                 // it's important to check if match has content | ||||
|                 if (end > -1 && (end - start) > delimiterLength) { | ||||
|                     return new MatchImpl(delimiter, start, end + delimiterLength); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     private MarkwonEditorUtils() { | ||||
|     } | ||||
| 
 | ||||
|     private static class MatchImpl implements Match { | ||||
| 
 | ||||
|         private final String delimiter; | ||||
|         private final int start; | ||||
|         private final int end; | ||||
| 
 | ||||
|         MatchImpl(@NonNull String delimiter, int start, int end) { | ||||
|             this.delimiter = delimiter; | ||||
|             this.start = start; | ||||
|             this.end = end; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public String delimiter() { | ||||
|             return delimiter; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public int start() { | ||||
|             return start; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public int end() { | ||||
|             return end; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         @NonNull | ||||
|         public String toString() { | ||||
|             return "MatchImpl{" + | ||||
|                     "delimiter='" + delimiter + '\'' + | ||||
|                     ", start=" + start + | ||||
|                     ", end=" + end + | ||||
|                     '}'; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,116 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.text.Spannable; | ||||
| import android.util.Log; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import java.util.HashMap; | ||||
| import java.util.List; | ||||
| import java.util.Locale; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| import static io.noties.markwon.editor.MarkwonEditorUtils.extractSpans; | ||||
| 
 | ||||
| /** | ||||
|  * Cache for spans that present in user input. These spans are reused between different | ||||
|  * {@link MarkwonEditor#process(Editable)} and {@link MarkwonEditor#preRender(Editable, MarkwonEditor.PreRenderResultListener)} | ||||
|  * calls. | ||||
|  * | ||||
|  * @see EditHandler#handleMarkdownSpan(PersistedSpans, Editable, String, Object, int, int) | ||||
|  * @see EditHandler#configurePersistedSpans(Builder) | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public abstract class PersistedSpans { | ||||
| 
 | ||||
|     public interface SpanFactory<T> { | ||||
|         @NonNull | ||||
|         T create(); | ||||
|     } | ||||
| 
 | ||||
|     public interface Builder { | ||||
|         @SuppressWarnings("UnusedReturnValue") | ||||
|         @NonNull | ||||
|         <T> Builder persistSpan(@NonNull Class<T> type, @NonNull SpanFactory<T> spanFactory); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public abstract <T> T get(@NonNull Class<T> type); | ||||
| 
 | ||||
|     abstract void removeUnused(); | ||||
| 
 | ||||
| 
 | ||||
|     @NonNull | ||||
|     static Provider provider() { | ||||
|         return new Provider(); | ||||
|     } | ||||
| 
 | ||||
|     static class Provider implements Builder { | ||||
| 
 | ||||
|         private final Map<Class<?>, SpanFactory> map = new HashMap<>(3); | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public <T> Builder persistSpan(@NonNull Class<T> type, @NonNull SpanFactory<T> spanFactory) { | ||||
|             if (map.put(type, spanFactory) != null) { | ||||
|                 Log.e("MD-EDITOR", String.format( | ||||
|                         Locale.ROOT, | ||||
|                         "Re-declaration of persisted span for '%s'", type.getName())); | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         PersistedSpans provide(@NonNull Spannable spannable) { | ||||
|             return new Impl(spannable, map); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     static class Impl extends PersistedSpans { | ||||
| 
 | ||||
|         private final Spannable spannable; | ||||
|         private final Map<Class<?>, SpanFactory> spans; | ||||
|         private final Map<Class<?>, List<Object>> map; | ||||
| 
 | ||||
|         Impl(@NonNull Spannable spannable, @NonNull Map<Class<?>, SpanFactory> spans) { | ||||
|             this.spannable = spannable; | ||||
|             this.spans = spans; | ||||
|             this.map = extractSpans(spannable, spans.keySet()); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public <T> T get(@NonNull Class<T> type) { | ||||
| 
 | ||||
|             final Object span; | ||||
| 
 | ||||
|             final List<Object> list = map.get(type); | ||||
|             if (list != null && list.size() > 0) { | ||||
|                 span = list.remove(0); | ||||
|             } else { | ||||
|                 final SpanFactory spanFactory = spans.get(type); | ||||
|                 if (spanFactory == null) { | ||||
|                     throw new IllegalStateException("Requested type `" + type.getName() + "` was " + | ||||
|                             "not registered, use PersistedSpans.Builder#persistSpan method to register"); | ||||
|                 } | ||||
|                 span = spanFactory.create(); | ||||
|             } | ||||
| 
 | ||||
|             //noinspection unchecked | ||||
|             return (T) span; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         void removeUnused() { | ||||
|             for (List<Object> spans : map.values()) { | ||||
|                 if (spans != null | ||||
|                         && spans.size() > 0) { | ||||
|                     for (Object span : spans) { | ||||
|                         spannable.removeSpan(span); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,17 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.TextPaint; | ||||
| import android.text.style.CharacterStyle; | ||||
| 
 | ||||
| import io.noties.markwon.utils.ColorUtils; | ||||
| 
 | ||||
| class PunctuationSpan extends CharacterStyle { | ||||
| 
 | ||||
|     private static final int DEF_PUNCTUATION_ALPHA = 75; | ||||
| 
 | ||||
|     @Override | ||||
|     public void updateDrawState(TextPaint tp) { | ||||
|         final int color = ColorUtils.applyAlpha(tp.getColor(), DEF_PUNCTUATION_ALPHA); | ||||
|         tp.setColor(color); | ||||
|     } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -0,0 +1,54 @@ | ||||
| package io.noties.markwon.editor.handler; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.text.Spanned; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.EmphasisSpan; | ||||
| import io.noties.markwon.editor.AbstractEditHandler; | ||||
| import io.noties.markwon.editor.MarkwonEditorUtils; | ||||
| import io.noties.markwon.editor.PersistedSpans; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class EmphasisEditHandler extends AbstractEditHandler<EmphasisSpan> { | ||||
| 
 | ||||
|     @Override | ||||
|     public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { | ||||
|         builder.persistSpan(EmphasisSpan.class, new PersistedSpans.SpanFactory<EmphasisSpan>() { | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public EmphasisSpan create() { | ||||
|                 return new EmphasisSpan(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void handleMarkdownSpan( | ||||
|             @NonNull PersistedSpans persistedSpans, | ||||
|             @NonNull Editable editable, | ||||
|             @NonNull String input, | ||||
|             @NonNull EmphasisSpan span, | ||||
|             int spanStart, | ||||
|             int spanTextLength) { | ||||
|         final MarkwonEditorUtils.Match match = | ||||
|                 MarkwonEditorUtils.findDelimited(input, spanStart, "*", "_"); | ||||
|         if (match != null) { | ||||
|             editable.setSpan( | ||||
|                     persistedSpans.get(EmphasisSpan.class), | ||||
|                     match.start(), | ||||
|                     match.end(), | ||||
|                     Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Class<EmphasisSpan> markdownSpanType() { | ||||
|         return EmphasisSpan.class; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,62 @@ | ||||
| package io.noties.markwon.editor.handler; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.text.Spanned; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.StrongEmphasisSpan; | ||||
| import io.noties.markwon.editor.AbstractEditHandler; | ||||
| import io.noties.markwon.editor.MarkwonEditorUtils; | ||||
| import io.noties.markwon.editor.PersistedSpans; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class StrongEmphasisEditHandler extends AbstractEditHandler<StrongEmphasisSpan> { | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static StrongEmphasisEditHandler create() { | ||||
|         return new StrongEmphasisEditHandler(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { | ||||
|         builder.persistSpan(StrongEmphasisSpan.class, new PersistedSpans.SpanFactory<StrongEmphasisSpan>() { | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public StrongEmphasisSpan create() { | ||||
|                 return new StrongEmphasisSpan(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void handleMarkdownSpan( | ||||
|             @NonNull PersistedSpans persistedSpans, | ||||
|             @NonNull Editable editable, | ||||
|             @NonNull String input, | ||||
|             @NonNull StrongEmphasisSpan span, | ||||
|             int spanStart, | ||||
|             int spanTextLength) { | ||||
|         // inline spans can delimit other inline spans, | ||||
|         //  for example: `**_~~hey~~_**`, this is why we must additionally find delimiter used | ||||
|         //  and its actual start/end positions | ||||
|         final MarkwonEditorUtils.Match match = | ||||
|                 MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__"); | ||||
|         if (match != null) { | ||||
|             editable.setSpan( | ||||
|                     persistedSpans.get(StrongEmphasisSpan.class), | ||||
|                     match.start(), | ||||
|                     match.end(), | ||||
|                     Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Class<StrongEmphasisSpan> markdownSpanType() { | ||||
|         return StrongEmphasisSpan.class; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,42 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.SpannableStringBuilder; | ||||
| 
 | ||||
| import org.junit.Test; | ||||
| import org.junit.runner.RunWith; | ||||
| import org.robolectric.RobolectricTestRunner; | ||||
| import org.robolectric.RuntimeEnvironment; | ||||
| import org.robolectric.annotation.Config; | ||||
| 
 | ||||
| import io.noties.markwon.Markwon; | ||||
| 
 | ||||
| import static org.junit.Assert.assertEquals; | ||||
| 
 | ||||
| @RunWith(RobolectricTestRunner.class) | ||||
| @Config(manifest = Config.NONE) | ||||
| public class MarkwonEditorImplTest { | ||||
| 
 | ||||
|     @Test | ||||
|     public void process() { | ||||
|         // create markwon | ||||
|         final Markwon markwon = Markwon.create(RuntimeEnvironment.application); | ||||
| 
 | ||||
|         // default punctuation | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
| 
 | ||||
|         final SpannableStringBuilder builder = new SpannableStringBuilder("**bold**"); | ||||
| 
 | ||||
|         editor.process(builder); | ||||
| 
 | ||||
|         final PunctuationSpan[] spans = builder.getSpans(0, builder.length(), PunctuationSpan.class); | ||||
|         assertEquals(2, spans.length); | ||||
| 
 | ||||
|         final PunctuationSpan first = spans[0]; | ||||
|         assertEquals(0, builder.getSpanStart(first)); | ||||
|         assertEquals(2, builder.getSpanEnd(first)); | ||||
| 
 | ||||
|         final PunctuationSpan second = spans[1]; | ||||
|         assertEquals(6, builder.getSpanStart(second)); | ||||
|         assertEquals(8, builder.getSpanEnd(second)); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,30 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import org.junit.Test; | ||||
| import org.junit.runner.RunWith; | ||||
| import org.robolectric.RobolectricTestRunner; | ||||
| import org.robolectric.annotation.Config; | ||||
| 
 | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.editor.MarkwonEditor.Builder; | ||||
| 
 | ||||
| import static org.junit.Assert.assertTrue; | ||||
| import static org.junit.Assert.fail; | ||||
| import static org.mockito.Mockito.mock; | ||||
| 
 | ||||
| @RunWith(RobolectricTestRunner.class) | ||||
| @Config(manifest = Config.NONE) | ||||
| public class MarkwonEditorTest { | ||||
| 
 | ||||
|     @Test | ||||
|     public void builder_no_config() { | ||||
|         // must create a default instance without exceptions | ||||
| 
 | ||||
|         try { | ||||
|             new Builder(mock(Markwon.class)).build(); | ||||
|             assertTrue(true); | ||||
|         } catch (Throwable t) { | ||||
|             fail(t.getMessage()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,142 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.widget.EditText; | ||||
| 
 | ||||
| import org.junit.Test; | ||||
| import org.junit.runner.RunWith; | ||||
| import org.mockito.ArgumentCaptor; | ||||
| import org.mockito.invocation.InvocationOnMock; | ||||
| import org.mockito.stubbing.Answer; | ||||
| import org.robolectric.RobolectricTestRunner; | ||||
| import org.robolectric.annotation.Config; | ||||
| 
 | ||||
| import java.util.concurrent.ExecutorService; | ||||
| 
 | ||||
| import io.noties.markwon.editor.MarkwonEditor.PreRenderResult; | ||||
| import io.noties.markwon.editor.MarkwonEditor.PreRenderResultListener; | ||||
| 
 | ||||
| import static org.junit.Assert.assertEquals; | ||||
| import static org.junit.Assert.fail; | ||||
| import static org.mockito.ArgumentMatchers.any; | ||||
| import static org.mockito.ArgumentMatchers.anyInt; | ||||
| import static org.mockito.ArgumentMatchers.eq; | ||||
| import static org.mockito.Mockito.RETURNS_MOCKS; | ||||
| import static org.mockito.Mockito.doAnswer; | ||||
| import static org.mockito.Mockito.mock; | ||||
| import static org.mockito.Mockito.times; | ||||
| import static org.mockito.Mockito.verify; | ||||
| import static org.mockito.Mockito.when; | ||||
| 
 | ||||
| @RunWith(RobolectricTestRunner.class) | ||||
| @Config(manifest = Config.NONE) | ||||
| public class MarkwonEditorTextWatcherTest { | ||||
| 
 | ||||
|     @Test | ||||
|     public void w_process() { | ||||
| 
 | ||||
|         final MarkwonEditor editor = mock(MarkwonEditor.class); | ||||
|         final Editable editable = mock(Editable.class); | ||||
| 
 | ||||
|         final MarkwonEditorTextWatcher watcher = MarkwonEditorTextWatcher.withProcess(editor); | ||||
| 
 | ||||
|         watcher.afterTextChanged(editable); | ||||
| 
 | ||||
|         verify(editor, times(1)).process(eq(editable)); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void w_pre_render() { | ||||
| 
 | ||||
|         final MarkwonEditor editor = mock(MarkwonEditor.class); | ||||
|         final Editable editable = mock(Editable.class); | ||||
|         final ExecutorService service = mock(ExecutorService.class); | ||||
|         final EditText editText = mock(EditText.class); | ||||
| 
 | ||||
|         when(editable.getSpans(anyInt(), anyInt(), any(Class.class))).thenReturn(new Object[0]); | ||||
| 
 | ||||
|         when(editText.getText()).thenReturn(editable); | ||||
| 
 | ||||
|         when(service.submit(any(Runnable.class))).thenAnswer(new Answer<Object>() { | ||||
|             @Override | ||||
|             public Object answer(InvocationOnMock invocation) { | ||||
|                 ((Runnable) invocation.getArgument(0)).run(); | ||||
|                 return null; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         doAnswer(new Answer() { | ||||
|             @Override | ||||
|             public Object answer(InvocationOnMock invocation) { | ||||
|                 ((Runnable) invocation.getArgument(0)).run(); | ||||
|                 return null; | ||||
|             } | ||||
|         }).when(editText).post(any(Runnable.class)); | ||||
| 
 | ||||
|         final MarkwonEditorTextWatcher watcher = MarkwonEditorTextWatcher.withPreRender( | ||||
|                 editor, | ||||
|                 service, | ||||
|                 editText); | ||||
| 
 | ||||
|         watcher.afterTextChanged(editable); | ||||
| 
 | ||||
|         final ArgumentCaptor<PreRenderResultListener> captor = | ||||
|                 ArgumentCaptor.forClass(PreRenderResultListener.class); | ||||
| 
 | ||||
|         verify(service, times(1)).submit(any(Runnable.class)); | ||||
|         verify(editor, times(1)).preRender(any(Editable.class), captor.capture()); | ||||
| 
 | ||||
|         final PreRenderResultListener listener = captor.getValue(); | ||||
|         final PreRenderResult result = mock(PreRenderResult.class); | ||||
| 
 | ||||
|         // for simplicity return the same editable instance (same hashCode) | ||||
|         when(result.resultEditable()).thenReturn(editable); | ||||
| 
 | ||||
|         listener.onPreRenderResult(result); | ||||
| 
 | ||||
|         // if we would check for hashCode then this method would've been invoked | ||||
| //        verify(result, times(1)).resultEditable(); | ||||
|         verify(result, times(1)).dispatchTo(eq(editable)); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void pre_render_posts_exception_to_main_thread() { | ||||
| 
 | ||||
|         final RuntimeException e = new RuntimeException(); | ||||
| 
 | ||||
|         final MarkwonEditor editor = mock(MarkwonEditor.class); | ||||
|         final ExecutorService service = mock(ExecutorService.class); | ||||
|         final EditText editText = mock(EditText.class, RETURNS_MOCKS); | ||||
| 
 | ||||
|         doAnswer(new Answer() { | ||||
|             @Override | ||||
|             public Object answer(InvocationOnMock invocation) { | ||||
|                 throw e; | ||||
|             } | ||||
|         }).when(editor).preRender(any(Editable.class), any(PreRenderResultListener.class)); | ||||
| 
 | ||||
|         when(service.submit(any(Runnable.class))).thenAnswer(new Answer<Object>() { | ||||
|             @Override | ||||
|             public Object answer(InvocationOnMock invocation) { | ||||
|                 ((Runnable) invocation.getArgument(0)).run(); | ||||
|                 return null; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         final ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class); | ||||
| 
 | ||||
|         final MarkwonEditorTextWatcher textWatcher = | ||||
|                 MarkwonEditorTextWatcher.withPreRender(editor, service, editText); | ||||
| 
 | ||||
|         textWatcher.afterTextChanged(mock(Editable.class, RETURNS_MOCKS)); | ||||
| 
 | ||||
|         verify(editText, times(1)).post(captor.capture()); | ||||
| 
 | ||||
|         try { | ||||
|             captor.getValue().run(); | ||||
|             fail(); | ||||
|         } catch (Throwable t) { | ||||
|             assertEquals(e, t.getCause()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,109 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.SpannableStringBuilder; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.junit.Test; | ||||
| import org.junit.runner.RunWith; | ||||
| import org.robolectric.RobolectricTestRunner; | ||||
| import org.robolectric.annotation.Config; | ||||
| 
 | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.Locale; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| import io.noties.markwon.editor.MarkwonEditorUtils.Match; | ||||
| 
 | ||||
| import static io.noties.markwon.editor.MarkwonEditorUtils.findDelimited; | ||||
| import static io.noties.markwon.editor.SpannableUtils.append; | ||||
| import static java.lang.String.format; | ||||
| import static org.junit.Assert.assertEquals; | ||||
| import static org.junit.Assert.assertNotNull; | ||||
| import static org.junit.Assert.assertNull; | ||||
| 
 | ||||
| @RunWith(RobolectricTestRunner.class) | ||||
| @Config(manifest = Config.NONE) | ||||
| public class MarkwonEditorUtilsTest { | ||||
| 
 | ||||
|     @Test | ||||
|     public void extract_spans() { | ||||
| 
 | ||||
|         final class One { | ||||
|         } | ||||
|         final class Two { | ||||
|         } | ||||
|         final class Three { | ||||
|         } | ||||
| 
 | ||||
|         final SpannableStringBuilder builder = new SpannableStringBuilder(); | ||||
|         append(builder, "one", new One()); | ||||
|         append(builder, "two", new Two(), new Two()); | ||||
|         append(builder, "three", new Three(), new Three(), new Three()); | ||||
| 
 | ||||
|         final Map<Class<?>, List<Object>> map = MarkwonEditorUtils.extractSpans( | ||||
|                 builder, | ||||
|                 Arrays.asList(One.class, Three.class)); | ||||
| 
 | ||||
|         assertEquals(2, map.size()); | ||||
| 
 | ||||
|         assertNotNull(map.get(One.class)); | ||||
|         assertNull(map.get(Two.class)); | ||||
|         assertNotNull(map.get(Three.class)); | ||||
| 
 | ||||
|         //noinspection ConstantConditions | ||||
|         assertEquals(1, map.get(One.class).size()); | ||||
|         //noinspection ConstantConditions | ||||
|         assertEquals(3, map.get(Three.class).size()); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void delimited_single() { | ||||
|         final String input = "**bold**"; | ||||
|         final Match match = findDelimited(input, 0, "**"); | ||||
|         assertMatched(input, match, "**", 0, input.length()); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void delimited_multiple() { | ||||
|         final String input = "**bold**"; | ||||
|         final Match match = findDelimited(input, 0, "**", "__"); | ||||
|         assertMatched(input, match, "**", 0, input.length()); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void delimited_em() { | ||||
|         // for example we will try to match `*` or `_` and our implementation will find first | ||||
|         final String input = "**_em_**"; // problematic for em... | ||||
|         final Match match = findDelimited(input, 0, "_", "*"); | ||||
|         assertMatched(input, match, "_", 2, 6); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void delimited_bold_em_strike() { | ||||
|         final String input = "**_~~dude~~_**"; | ||||
| 
 | ||||
|         final Match bold = findDelimited(input, 0, "**", "__"); | ||||
|         final Match em = findDelimited(input, 0, "*", "_"); | ||||
|         final Match strike = findDelimited(input, 0, "~~"); | ||||
| 
 | ||||
|         assertMatched(input, bold, "**", 0, input.length()); | ||||
|         assertMatched(input, em, "_", 2, 12); | ||||
|         assertMatched(input, strike, "~~", 3, 11); | ||||
|     } | ||||
| 
 | ||||
|     private static void assertMatched( | ||||
|             @NonNull String input, | ||||
|             @Nullable Match match, | ||||
|             @NonNull String delimiter, | ||||
|             int start, | ||||
|             int end) { | ||||
|         assertNotNull(format(Locale.ROOT, "delimiter: '%s', input: '%s'", delimiter, input), match); | ||||
|         final String m = format(Locale.ROOT, "input: '%s', match: %s", input, match); | ||||
|         assertEquals(m, delimiter, match.delimiter()); | ||||
|         assertEquals(m, start, match.start()); | ||||
|         assertEquals(m, end, match.end()); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,96 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.SpannableStringBuilder; | ||||
| 
 | ||||
| import org.junit.Test; | ||||
| import org.junit.runner.RunWith; | ||||
| import org.robolectric.RobolectricTestRunner; | ||||
| import org.robolectric.annotation.Config; | ||||
| 
 | ||||
| import java.util.Collections; | ||||
| import java.util.HashMap; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| import io.noties.markwon.editor.PersistedSpans.Impl; | ||||
| import io.noties.markwon.editor.PersistedSpans.SpanFactory; | ||||
| 
 | ||||
| import static io.noties.markwon.editor.SpannableUtils.append; | ||||
| import static org.junit.Assert.assertEquals; | ||||
| import static org.junit.Assert.assertTrue; | ||||
| import static org.junit.Assert.fail; | ||||
| import static org.mockito.Mockito.mock; | ||||
| import static org.mockito.Mockito.never; | ||||
| import static org.mockito.Mockito.times; | ||||
| import static org.mockito.Mockito.verify; | ||||
| 
 | ||||
| @RunWith(RobolectricTestRunner.class) | ||||
| @Config(manifest = Config.NONE) | ||||
| public class PersistedSpansTest { | ||||
| 
 | ||||
|     @Test | ||||
|     public void not_included() { | ||||
|         // When a span that is not included is requested -> exception is raised | ||||
| 
 | ||||
|         final Map<Class<?>, SpanFactory> map = Collections.emptyMap(); | ||||
| 
 | ||||
|         final Impl impl = new Impl(new SpannableStringBuilder(), map); | ||||
| 
 | ||||
|         try { | ||||
|             impl.get(Object.class); | ||||
|             fail(); | ||||
|         } catch (IllegalStateException e) { | ||||
|             assertTrue(e.getMessage(), e.getMessage().contains("not registered, use PersistedSpans.Builder#persistSpan method to register")); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void re_use() { | ||||
|         // when a span is present in supplied spannable -> it will be used | ||||
| 
 | ||||
|         final class One { | ||||
|         } | ||||
| 
 | ||||
|         final SpannableStringBuilder builder = new SpannableStringBuilder(); | ||||
|         final One one = new One(); | ||||
|         append(builder, "One", one); | ||||
| 
 | ||||
|         final Map<Class<?>, SpanFactory> map = new HashMap<Class<?>, SpanFactory>() {{ | ||||
|             // null in case it _will_ be used -> thus NPE | ||||
|             put(One.class, null); | ||||
|         }}; | ||||
| 
 | ||||
|         final Impl impl = new Impl(builder, map); | ||||
| 
 | ||||
|         assertEquals(one, impl.get(One.class)); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void factory_create() { | ||||
|         // when span is not present in spannable -> new one will be created via factory | ||||
| 
 | ||||
|         final class Two { | ||||
|         } | ||||
| 
 | ||||
|         final SpannableStringBuilder builder = new SpannableStringBuilder(); | ||||
|         final Two two = new Two(); | ||||
|         append(builder, "two", two); | ||||
| 
 | ||||
|         final SpanFactory factory = mock(SpanFactory.class); | ||||
| 
 | ||||
|         final Map<Class<?>, SpanFactory> map = new HashMap<Class<?>, SpanFactory>() {{ | ||||
|             put(Two.class, factory); | ||||
|         }}; | ||||
| 
 | ||||
|         final Impl impl = new Impl(builder, map); | ||||
| 
 | ||||
|         // first one will be the same as we had created before, | ||||
|         // second one will be created via factory | ||||
| 
 | ||||
|         assertEquals(two, impl.get(Two.class)); | ||||
| 
 | ||||
|         verify(factory, never()).create(); | ||||
| 
 | ||||
|         impl.get(Two.class); | ||||
|         verify(factory, times(1)).create(); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,21 @@ | ||||
| package io.noties.markwon.editor; | ||||
| 
 | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.Spanned; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| abstract class SpannableUtils { | ||||
| 
 | ||||
|     static void append(@NonNull SpannableStringBuilder builder, @NonNull String text, Object... spans) { | ||||
|         final int start = builder.length(); | ||||
|         builder.append(text); | ||||
|         final int end = builder.length(); | ||||
|         for (Object span : spans) { | ||||
|             builder.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private SpannableUtils() { | ||||
|     } | ||||
| } | ||||
							
								
								
									
										3
									
								
								markwon-image-coil/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								markwon-image-coil/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| # Images (Coil) | ||||
| 
 | ||||
| https://noties.io/Markwon/docs/v4/image-coil/ | ||||
							
								
								
									
										21
									
								
								markwon-image-coil/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								markwon-image-coil/build.gradle
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | ||||
| apply plugin: 'com.android.library' | ||||
| 
 | ||||
| android { | ||||
| 
 | ||||
|     compileSdkVersion config['compile-sdk'] | ||||
|     buildToolsVersion config['build-tools'] | ||||
| 
 | ||||
|     defaultConfig { | ||||
|         minSdkVersion config['min-sdk'] | ||||
|         targetSdkVersion config['target-sdk'] | ||||
|         versionCode 1 | ||||
|         versionName version | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| dependencies { | ||||
|     api project(':markwon-core') | ||||
|     api deps['coil'] | ||||
| } | ||||
| 
 | ||||
| registerArtifact(this) | ||||
							
								
								
									
										4
									
								
								markwon-image-coil/gradle.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								markwon-image-coil/gradle.properties
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| POM_NAME=Image Coil | ||||
| POM_ARTIFACT_ID=image-coil | ||||
| POM_DESCRIPTION=Markwon image loading module (based on Coil library) | ||||
| POM_PACKAGING=aar | ||||
							
								
								
									
										1
									
								
								markwon-image-coil/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								markwon-image-coil/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <manifest package="io.noties.markwon.image.coil" /> | ||||
| @ -0,0 +1,187 @@ | ||||
| package io.noties.markwon.image.coil; | ||||
| 
 | ||||
| import android.content.Context; | ||||
| import android.graphics.drawable.Drawable; | ||||
| import android.text.Spanned; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.node.Image; | ||||
| 
 | ||||
| import java.util.HashMap; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| import coil.Coil; | ||||
| import coil.ImageLoader; | ||||
| import coil.api.ImageLoaders; | ||||
| import coil.request.LoadRequest; | ||||
| import coil.request.RequestDisposable; | ||||
| import coil.target.Target; | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.MarkwonConfiguration; | ||||
| import io.noties.markwon.MarkwonSpansFactory; | ||||
| import io.noties.markwon.image.AsyncDrawable; | ||||
| import io.noties.markwon.image.AsyncDrawableLoader; | ||||
| import io.noties.markwon.image.AsyncDrawableScheduler; | ||||
| import io.noties.markwon.image.DrawableUtils; | ||||
| import io.noties.markwon.image.ImageSpanFactory; | ||||
| 
 | ||||
| /** | ||||
|  * @author Tyler Wong | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class CoilImagesPlugin extends AbstractMarkwonPlugin { | ||||
| 
 | ||||
|     public interface CoilStore { | ||||
| 
 | ||||
|         @NonNull | ||||
|         LoadRequest load(@NonNull AsyncDrawable drawable); | ||||
| 
 | ||||
|         void cancel(@NonNull RequestDisposable disposable); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static CoilImagesPlugin create(@NonNull final Context context) { | ||||
|         return create(new CoilStore() { | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public LoadRequest load(@NonNull AsyncDrawable drawable) { | ||||
|                 return ImageLoaders.newLoadBuilder(Coil.loader(), context) | ||||
|                         .data(drawable.getDestination()) | ||||
|                         .build(); | ||||
|             } | ||||
| 
 | ||||
|             @Override | ||||
|             public void cancel(@NonNull RequestDisposable disposable) { | ||||
|                 disposable.dispose(); | ||||
|             } | ||||
|         }, Coil.loader()); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static CoilImagesPlugin create(@NonNull final Context context, | ||||
|                                           @NonNull final ImageLoader imageLoader) { | ||||
|         return create(new CoilStore() { | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public LoadRequest load(@NonNull AsyncDrawable drawable) { | ||||
|                 return ImageLoaders.newLoadBuilder(imageLoader, context) | ||||
|                         .data(drawable.getDestination()) | ||||
|                         .build(); | ||||
|             } | ||||
| 
 | ||||
|             @Override | ||||
|             public void cancel(@NonNull RequestDisposable disposable) { | ||||
|                 disposable.dispose(); | ||||
|             } | ||||
|         }, imageLoader); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static CoilImagesPlugin create(@NonNull final CoilStore coilStore, | ||||
|                                           @NonNull final ImageLoader imageLoader) { | ||||
|         return new CoilImagesPlugin(coilStore, imageLoader); | ||||
|     } | ||||
| 
 | ||||
|     private final CoilAsyncDrawableLoader coilAsyncDrawableLoader; | ||||
| 
 | ||||
|     @SuppressWarnings("WeakerAccess") | ||||
|     CoilImagesPlugin(@NonNull CoilStore coilStore, @NonNull ImageLoader imageLoader) { | ||||
|         this.coilAsyncDrawableLoader = new CoilAsyncDrawableLoader(coilStore, imageLoader); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { | ||||
|         builder.setFactory(Image.class, new ImageSpanFactory()); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { | ||||
|         builder.asyncDrawableLoader(coilAsyncDrawableLoader); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { | ||||
|         AsyncDrawableScheduler.unschedule(textView); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void afterSetText(@NonNull TextView textView) { | ||||
|         AsyncDrawableScheduler.schedule(textView); | ||||
|     } | ||||
| 
 | ||||
|     private static class CoilAsyncDrawableLoader extends AsyncDrawableLoader { | ||||
| 
 | ||||
|         private final CoilStore coilStore; | ||||
|         private final ImageLoader imageLoader; | ||||
|         private final Map<AsyncDrawable, RequestDisposable> cache = new HashMap<>(2); | ||||
| 
 | ||||
|         CoilAsyncDrawableLoader(@NonNull CoilStore coilStore, @NonNull ImageLoader imageLoader) { | ||||
|             this.coilStore = coilStore; | ||||
|             this.imageLoader = imageLoader; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void load(@NonNull AsyncDrawable drawable) { | ||||
|             final Target target = new AsyncDrawableTarget(drawable); | ||||
|             LoadRequest request = coilStore.load(drawable).newBuilder() | ||||
|                     .target(target) | ||||
|                     .build(); | ||||
|             RequestDisposable disposable = imageLoader.load(request); | ||||
|             cache.put(drawable, disposable); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void cancel(@NonNull AsyncDrawable drawable) { | ||||
|             final RequestDisposable disposable = cache.remove(drawable); | ||||
|             if (disposable != null) { | ||||
|                 coilStore.cancel(disposable); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         @Nullable | ||||
|         @Override | ||||
|         public Drawable placeholder(@NonNull AsyncDrawable drawable) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         private class AsyncDrawableTarget implements Target { | ||||
| 
 | ||||
|             private final AsyncDrawable drawable; | ||||
| 
 | ||||
|             AsyncDrawableTarget(@NonNull AsyncDrawable drawable) { | ||||
|                 this.drawable = drawable; | ||||
|             } | ||||
| 
 | ||||
|             @Override | ||||
|             public void onSuccess(@NonNull Drawable loadedDrawable) { | ||||
|                 if (cache.remove(drawable) != null) { | ||||
|                     if (drawable.isAttached()) { | ||||
|                         DrawableUtils.applyIntrinsicBoundsIfEmpty(loadedDrawable); | ||||
|                         drawable.setResult(loadedDrawable); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             @Override | ||||
|             public void onStart(@Nullable Drawable placeholder) { | ||||
|                 if (placeholder != null && drawable.isAttached()) { | ||||
|                     DrawableUtils.applyIntrinsicBoundsIfEmpty(placeholder); | ||||
|                     drawable.setResult(placeholder); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             @Override | ||||
|             public void onError(@Nullable Drawable errorDrawable) { | ||||
|                 if (cache.remove(drawable) != null) { | ||||
|                     if (errorDrawable != null && drawable.isAttached()) { | ||||
|                         DrawableUtils.applyIntrinsicBoundsIfEmpty(errorDrawable); | ||||
|                         drawable.setResult(errorDrawable); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,51 @@ | ||||
| package io.noties.markwon.image.svg; | ||||
| 
 | ||||
| import android.graphics.Picture; | ||||
| import android.graphics.drawable.Drawable; | ||||
| import android.graphics.drawable.PictureDrawable; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import com.caverock.androidsvg.SVG; | ||||
| import com.caverock.androidsvg.SVGParseException; | ||||
| 
 | ||||
| import java.io.InputStream; | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| import io.noties.markwon.image.MediaDecoder; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class SvgPictureMediaDecoder extends MediaDecoder { | ||||
| 
 | ||||
|     public static final String CONTENT_TYPE = "image/svg+xml"; | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static SvgPictureMediaDecoder create() { | ||||
|         return new SvgPictureMediaDecoder(); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Drawable decode(@Nullable String contentType, @NonNull InputStream inputStream) { | ||||
| 
 | ||||
|         final SVG svg; | ||||
|         try { | ||||
|             svg = SVG.getFromInputStream(inputStream); | ||||
|         } catch (SVGParseException e) { | ||||
|             throw new IllegalStateException("Exception decoding SVG", e); | ||||
|         } | ||||
| 
 | ||||
|         final Picture picture = svg.renderToPicture(); | ||||
|         return new PictureDrawable(picture); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<String> supportedTypes() { | ||||
|         return Collections.singleton(CONTENT_TYPE); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								markwon-inline-parser/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								markwon-inline-parser/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| # Inline parser | ||||
| 
 | ||||
| **Experimental** due to usage of internal (but still visible) classes of commonmark-java: | ||||
| 
 | ||||
| ```java | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.Delimiter; | ||||
| import org.commonmark.internal.ReferenceParser; | ||||
| import org.commonmark.internal.util.Escaping; | ||||
| import org.commonmark.internal.util.Html5Entities; | ||||
| import org.commonmark.internal.util.Parsing; | ||||
| import org.commonmark.internal.inline.AsteriskDelimiterProcessor; | ||||
| import org.commonmark.internal.inline.UnderscoreDelimiterProcessor; | ||||
| ``` | ||||
| 
 | ||||
| `StaggeredDelimiterProcessor` class source is copied (required for InlineParser) | ||||
							
								
								
									
										26
									
								
								markwon-inline-parser/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								markwon-inline-parser/build.gradle
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | ||||
| apply plugin: 'com.android.library' | ||||
| 
 | ||||
| android { | ||||
| 
 | ||||
|     compileSdkVersion config['compile-sdk'] | ||||
|     buildToolsVersion config['build-tools'] | ||||
| 
 | ||||
|     defaultConfig { | ||||
|         minSdkVersion config['min-sdk'] | ||||
|         targetSdkVersion config['target-sdk'] | ||||
|         versionCode 1 | ||||
|         versionName version | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| dependencies { | ||||
|     api deps['x-annotations'] | ||||
|     api deps['commonmark'] | ||||
| 
 | ||||
|     deps['test'].with { | ||||
|         testImplementation it['junit'] | ||||
|         testImplementation it['commonmark-test-util'] | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| registerArtifact(this) | ||||
							
								
								
									
										4
									
								
								markwon-inline-parser/gradle.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								markwon-inline-parser/gradle.properties
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| POM_NAME=Inline Parser | ||||
| POM_ARTIFACT_ID=inline-parser | ||||
| POM_DESCRIPTION=Markwon customizable commonmark-java InlineParser | ||||
| POM_PACKAGING=aar | ||||
							
								
								
									
										1
									
								
								markwon-inline-parser/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								markwon-inline-parser/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <manifest package="io.noties.markwon.inlineparser" /> | ||||
| @ -0,0 +1,44 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| /** | ||||
|  * Parses autolinks, for example {@code <me@mydoma.in>} | ||||
|  * | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class AutolinkInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|     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]*>"); | ||||
| 
 | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '<'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node 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)); | ||||
|             return node; | ||||
|         } 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)); | ||||
|             return node; | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,35 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.node.HardLineBreak; | ||||
| import org.commonmark.node.Node; | ||||
| 
 | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class BackslashInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|     private static final Pattern ESCAPABLE = MarkwonInlineParser.ESCAPABLE; | ||||
| 
 | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '\\'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         index++; | ||||
|         Node node; | ||||
|         if (peek() == '\n') { | ||||
|             node = new HardLineBreak(); | ||||
|             index++; | ||||
|         } else if (index < input.length() && ESCAPABLE.matcher(input.substring(index, index + 1)).matches()) { | ||||
|             node = text(input, index, index + 1); | ||||
|             index++; | ||||
|         } else { | ||||
|             node = text("\\"); | ||||
|         } | ||||
|         return node; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,56 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.internal.util.Parsing; | ||||
| import org.commonmark.node.Code; | ||||
| import org.commonmark.node.Node; | ||||
| 
 | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| /** | ||||
|  * Parses inline code surrounded with {@code `} chars {@code `code`} | ||||
|  * | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class BackticksInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|     private static final Pattern TICKS = Pattern.compile("`+"); | ||||
| 
 | ||||
|     private static final Pattern TICKS_HERE = Pattern.compile("^`+"); | ||||
| 
 | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '`'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         String ticks = match(TICKS_HERE); | ||||
|         if (ticks == null) { | ||||
|             return null; | ||||
|         } | ||||
|         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()); | ||||
|                 content = content.replace('\n', ' '); | ||||
| 
 | ||||
|                 // spec: If the resulting string both begins and ends with a space character, but does not consist | ||||
|                 // entirely of space characters, a single space character is removed from the front and back. | ||||
|                 if (content.length() >= 3 && | ||||
|                         content.charAt(0) == ' ' && | ||||
|                         content.charAt(content.length() - 1) == ' ' && | ||||
|                         Parsing.hasNonSpace(content)) { | ||||
|                     content = content.substring(1, content.length() - 1); | ||||
|                 } | ||||
| 
 | ||||
|                 node.setLiteral(content); | ||||
|                 return node; | ||||
|             } | ||||
|         } | ||||
|         // If we got here, we didn't match a closing backtick sequence. | ||||
|         index = afterOpenTicks; | ||||
|         return text(ticks); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,35 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| /** | ||||
|  * Parses markdown images {@code } | ||||
|  * | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class BangInlineProcessor extends InlineProcessor { | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '!'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         int startIndex = index; | ||||
|         index++; | ||||
|         if (peek() == '[') { | ||||
|             index++; | ||||
| 
 | ||||
|             Text node = text("!["); | ||||
| 
 | ||||
|             // Add entry to stack for this opener | ||||
|             addBracket(Bracket.image(node, startIndex + 1, lastBracket(), lastDelimiter())); | ||||
| 
 | ||||
|             return node; | ||||
|         } else { | ||||
|             return text("!"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,140 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| 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.LinkReferenceDefinition; | ||||
| import org.commonmark.node.Node; | ||||
| 
 | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| import static io.noties.markwon.inlineparser.InlineParserUtils.mergeChildTextNodes; | ||||
| 
 | ||||
| /** | ||||
|  * Parses markdown link or image, relies on {@link OpenBracketInlineProcessor} | ||||
|  * to handle start of these elements | ||||
|  * | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class CloseBracketInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|     private static final Pattern WHITESPACE = MarkwonInlineParser.WHITESPACE; | ||||
| 
 | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return ']'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         index++; | ||||
|         int startIndex = index; | ||||
| 
 | ||||
|         // Get previous `[` or `![` | ||||
|         Bracket opener = lastBracket(); | ||||
|         if (opener == null) { | ||||
|             // No matching opener, just return a literal. | ||||
|             return text("]"); | ||||
|         } | ||||
| 
 | ||||
|         if (!opener.allowed) { | ||||
|             // Matching opener but it's not allowed, just return a literal. | ||||
|             removeLastBracket(); | ||||
|             return text("]"); | ||||
|         } | ||||
| 
 | ||||
|         // 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; | ||||
|             parseLinkLabel(); | ||||
|             int labelLength = index - beforeLabel; | ||||
|             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) { | ||||
|                 String label = Escaping.normalizeReference(ref); | ||||
|                 LinkReferenceDefinition definition = context.getLinkReferenceDefinition(label); | ||||
|                 if (definition != null) { | ||||
|                     dest = definition.getDestination(); | ||||
|                     title = definition.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; | ||||
|             } | ||||
| 
 | ||||
|             // 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 linkOrImage; | ||||
| 
 | ||||
|         } else { // no link or image | ||||
|             index = startIndex; | ||||
|             removeLastBracket(); | ||||
| 
 | ||||
|             return text("]"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,32 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.internal.util.Escaping; | ||||
| import org.commonmark.internal.util.Html5Entities; | ||||
| import org.commonmark.node.Node; | ||||
| 
 | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| /** | ||||
|  * Parses HTML entities {@code &} | ||||
|  * | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class EntityInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|     private static final Pattern ENTITY_HERE = Pattern.compile('^' + Escaping.ENTITY, Pattern.CASE_INSENSITIVE); | ||||
| 
 | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '&'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         String m; | ||||
|         if ((m = match(ENTITY_HERE)) != null) { | ||||
|             return text(Html5Entities.entityToString(m)); | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,40 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.internal.util.Parsing; | ||||
| import org.commonmark.node.HtmlInline; | ||||
| import org.commonmark.node.Node; | ||||
| 
 | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| /** | ||||
|  * Parses inline HTML tags | ||||
|  * | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class HtmlInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|     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); | ||||
| 
 | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '<'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         String m = match(HTML_TAG); | ||||
|         if (m != null) { | ||||
|             HtmlInline node = new HtmlInline(); | ||||
|             node.setLiteral(m); | ||||
|             return node; | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,77 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public abstract class InlineParserUtils { | ||||
| 
 | ||||
|     public static void mergeTextNodesBetweenExclusive(Node fromNode, Node toNode) { | ||||
|         // No nodes between them | ||||
|         if (fromNode == toNode || fromNode.getNext() == toNode) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         mergeTextNodesInclusive(fromNode.getNext(), toNode.getPrevious()); | ||||
|     } | ||||
| 
 | ||||
|     public static 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()); | ||||
|     } | ||||
| 
 | ||||
|     public static 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); | ||||
|     } | ||||
| 
 | ||||
|     public static 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); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private InlineParserUtils() { | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,141 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.Delimiter; | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| import java.util.Map; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| /** | ||||
|  * @see AutolinkInlineProcessor | ||||
|  * @see BackslashInlineProcessor | ||||
|  * @see BackticksInlineProcessor | ||||
|  * @see BangInlineProcessor | ||||
|  * @see CloseBracketInlineProcessor | ||||
|  * @see EntityInlineProcessor | ||||
|  * @see HtmlInlineProcessor | ||||
|  * @see NewLineInlineProcessor | ||||
|  * @see OpenBracketInlineProcessor | ||||
|  * @see MarkwonInlineParser.FactoryBuilder#addInlineProcessor(InlineProcessor) | ||||
|  * @see MarkwonInlineParser.FactoryBuilder#excludeInlineProcessor(Class) | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public abstract class InlineProcessor { | ||||
| 
 | ||||
|     /** | ||||
|      * Special character that triggers parsing attempt | ||||
|      */ | ||||
|     public abstract char specialCharacter(); | ||||
| 
 | ||||
|     /** | ||||
|      * @return boolean indicating if parsing succeeded | ||||
|      */ | ||||
|     @Nullable | ||||
|     protected abstract Node parse(); | ||||
| 
 | ||||
| 
 | ||||
|     protected MarkwonInlineParserContext context; | ||||
|     protected Node block; | ||||
|     protected String input; | ||||
|     protected int index; | ||||
| 
 | ||||
|     @Nullable | ||||
|     public Node parse(@NonNull MarkwonInlineParserContext context) { | ||||
|         this.context = context; | ||||
|         this.block = context.block(); | ||||
|         this.input = context.input(); | ||||
|         this.index = context.index(); | ||||
| 
 | ||||
|         final Node result = parse(); | ||||
| 
 | ||||
|         // synchronize index | ||||
|         context.setIndex(index); | ||||
| 
 | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     protected Bracket lastBracket() { | ||||
|         return context.lastBracket(); | ||||
|     } | ||||
| 
 | ||||
|     protected Delimiter lastDelimiter() { | ||||
|         return context.lastDelimiter(); | ||||
|     } | ||||
| 
 | ||||
|     protected void addBracket(Bracket bracket) { | ||||
|         context.addBracket(bracket); | ||||
|     } | ||||
| 
 | ||||
|     protected void removeLastBracket() { | ||||
|         context.removeLastBracket(); | ||||
|     } | ||||
| 
 | ||||
|     protected void spnl() { | ||||
|         context.setIndex(index); | ||||
|         context.spnl(); | ||||
|         index = context.index(); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     protected String match(@NonNull Pattern re) { | ||||
|         // before trying to match, we must notify context about our index (which we store additionally here) | ||||
|         context.setIndex(index); | ||||
| 
 | ||||
|         final String result = context.match(re); | ||||
| 
 | ||||
|         // after match we must reflect index change here | ||||
|         this.index = context.index(); | ||||
| 
 | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     protected String parseLinkDestination() { | ||||
|         context.setIndex(index); | ||||
|         final String result = context.parseLinkDestination(); | ||||
|         this.index = context.index(); | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     protected String parseLinkTitle() { | ||||
|         context.setIndex(index); | ||||
|         final String result = context.parseLinkTitle(); | ||||
|         this.index = context.index(); | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     protected int parseLinkLabel() { | ||||
|         context.setIndex(index); | ||||
|         final int result = context.parseLinkLabel(); | ||||
|         this.index = context.index(); | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     protected void processDelimiters(Delimiter stackBottom) { | ||||
|         context.setIndex(index); | ||||
|         context.processDelimiters(stackBottom); | ||||
|         this.index = context.index(); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     protected Text text(@NonNull String text) { | ||||
|         return context.text(text); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     protected Text text(@NonNull String text, int start, int end) { | ||||
|         return context.text(text, start, end); | ||||
|     } | ||||
| 
 | ||||
|     protected char peek() { | ||||
|         context.setIndex(index); | ||||
|         return context.peek(); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,824 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.Delimiter; | ||||
| import org.commonmark.internal.inline.AsteriskDelimiterProcessor; | ||||
| import org.commonmark.internal.inline.UnderscoreDelimiterProcessor; | ||||
| import org.commonmark.internal.util.Escaping; | ||||
| import org.commonmark.internal.util.LinkScanner; | ||||
| import org.commonmark.node.LinkReferenceDefinition; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| import org.commonmark.parser.InlineParser; | ||||
| import org.commonmark.parser.InlineParserContext; | ||||
| import org.commonmark.parser.InlineParserFactory; | ||||
| import org.commonmark.parser.delimiter.DelimiterProcessor; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.BitSet; | ||||
| import java.util.HashMap; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import java.util.Set; | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| import static io.noties.markwon.inlineparser.InlineParserUtils.mergeChildTextNodes; | ||||
| import static io.noties.markwon.inlineparser.InlineParserUtils.mergeTextNodesBetweenExclusive; | ||||
| 
 | ||||
| /** | ||||
|  * @see #factoryBuilder() | ||||
|  * @see #factoryBuilderNoDefaults() | ||||
|  * @see FactoryBuilder | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class MarkwonInlineParser implements InlineParser, MarkwonInlineParserContext { | ||||
| 
 | ||||
|     @SuppressWarnings("unused") | ||||
|     public interface FactoryBuilder { | ||||
| 
 | ||||
|         /** | ||||
|          * @see InlineProcessor | ||||
|          */ | ||||
|         @NonNull | ||||
|         FactoryBuilder addInlineProcessor(@NonNull InlineProcessor processor); | ||||
| 
 | ||||
|         /** | ||||
|          * @see AsteriskDelimiterProcessor | ||||
|          * @see UnderscoreDelimiterProcessor | ||||
|          */ | ||||
|         @NonNull | ||||
|         FactoryBuilder addDelimiterProcessor(@NonNull DelimiterProcessor processor); | ||||
| 
 | ||||
|         /** | ||||
|          * Indicate if markdown references are enabled. By default = `true` | ||||
|          */ | ||||
|         @NonNull | ||||
|         FactoryBuilder referencesEnabled(boolean referencesEnabled); | ||||
| 
 | ||||
|         @NonNull | ||||
|         FactoryBuilder excludeInlineProcessor(@NonNull Class<? extends InlineProcessor> processor); | ||||
| 
 | ||||
|         @NonNull | ||||
|         FactoryBuilder excludeDelimiterProcessor(@NonNull Class<? extends DelimiterProcessor> processor); | ||||
| 
 | ||||
|         @NonNull | ||||
|         InlineParserFactory build(); | ||||
|     } | ||||
| 
 | ||||
|     public interface FactoryBuilderNoDefaults extends FactoryBuilder { | ||||
|         /** | ||||
|          * Includes all default delimiter and inline processors, and sets {@code referencesEnabled=true}. | ||||
|          * Useful with subsequent calls to {@link #excludeInlineProcessor(Class)} or {@link #excludeDelimiterProcessor(Class)} | ||||
|          */ | ||||
|         @NonNull | ||||
|         FactoryBuilder includeDefaults(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates an instance of {@link FactoryBuilder} and includes all defaults. | ||||
|      * | ||||
|      * @see #factoryBuilderNoDefaults() | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static FactoryBuilder factoryBuilder() { | ||||
|         return new FactoryBuilderImpl().includeDefaults(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * NB, this return an <em>empty</em> builder, so if no {@link FactoryBuilderNoDefaults#includeDefaults()} | ||||
|      * is called, it means effectively <strong>no inline parsing</strong> (unless further calls | ||||
|      * to {@link FactoryBuilder#addInlineProcessor(InlineProcessor)} or {@link FactoryBuilder#addDelimiterProcessor(DelimiterProcessor)}). | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static FactoryBuilderNoDefaults factoryBuilderNoDefaults() { | ||||
|         return new FactoryBuilderImpl(); | ||||
|     } | ||||
| 
 | ||||
|     private static final String ASCII_PUNCTUATION = "!\"#\\$%&'\\(\\)\\*\\+,\\-\\./:;<=>\\?@\\[\\\\\\]\\^_`\\{\\|\\}~"; | ||||
|     private static final Pattern PUNCTUATION = Pattern | ||||
|             .compile("^[" + ASCII_PUNCTUATION + "\\p{Pc}\\p{Pd}\\p{Pe}\\p{Pf}\\p{Pi}\\p{Po}\\p{Ps}]"); | ||||
| 
 | ||||
|     private static final Pattern SPNL = Pattern.compile("^ *(?:\n *)?"); | ||||
| 
 | ||||
|     private static final Pattern UNICODE_WHITESPACE_CHAR = Pattern.compile("^[\\p{Zs}\t\r\n\f]"); | ||||
| 
 | ||||
|     static final Pattern ESCAPABLE = Pattern.compile('^' + Escaping.ESCAPABLE); | ||||
|     static final Pattern WHITESPACE = Pattern.compile("\\s+"); | ||||
| 
 | ||||
|     private final InlineParserContext inlineParserContext; | ||||
| 
 | ||||
|     private final boolean referencesEnabled; | ||||
| 
 | ||||
|     private final BitSet specialCharacters; | ||||
|     private final Map<Character, List<InlineProcessor>> inlineProcessors; | ||||
|     private final Map<Character, DelimiterProcessor> delimiterProcessors; | ||||
| 
 | ||||
|     // currently we still hold a reference to it because we decided not to | ||||
|     //  pass previous node argument to inline-processors (current usage is limited with NewLineInlineProcessor) | ||||
|     private Node block; | ||||
|     private String input; | ||||
|     private int index; | ||||
| 
 | ||||
|     /** | ||||
|      * 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>![)</code>). | ||||
|      */ | ||||
|     private Bracket lastBracket; | ||||
| 
 | ||||
|     // might we construct these in factory? | ||||
|     public MarkwonInlineParser( | ||||
|             @NonNull InlineParserContext inlineParserContext, | ||||
|             boolean referencesEnabled, | ||||
|             @NonNull List<InlineProcessor> inlineProcessors, | ||||
|             @NonNull List<DelimiterProcessor> delimiterProcessors) { | ||||
|         this.inlineParserContext = inlineParserContext; | ||||
|         this.referencesEnabled = referencesEnabled; | ||||
|         this.inlineProcessors = calculateInlines(inlineProcessors); | ||||
|         this.delimiterProcessors = calculateDelimiterProcessors(delimiterProcessors); | ||||
|         this.specialCharacters = calculateSpecialCharacters( | ||||
|                 this.inlineProcessors.keySet(), | ||||
|                 this.delimiterProcessors.keySet()); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     private static Map<Character, List<InlineProcessor>> calculateInlines(@NonNull List<InlineProcessor> inlines) { | ||||
|         final Map<Character, List<InlineProcessor>> map = new HashMap<>(inlines.size()); | ||||
|         List<InlineProcessor> list; | ||||
|         for (InlineProcessor inlineProcessor : inlines) { | ||||
|             final char character = inlineProcessor.specialCharacter(); | ||||
|             list = map.get(character); | ||||
|             if (list == null) { | ||||
|                 list = new ArrayList<>(1); | ||||
|                 map.put(character, list); | ||||
|             } | ||||
|             list.add(inlineProcessor); | ||||
|         } | ||||
|         return map; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     private static BitSet calculateSpecialCharacters(Set<Character> inlineCharacters, Set<Character> delimiterCharacters) { | ||||
|         final BitSet bitSet = new BitSet(); | ||||
|         for (Character c : inlineCharacters) { | ||||
|             bitSet.set(c); | ||||
|         } | ||||
|         for (Character c : delimiterCharacters) { | ||||
|             bitSet.set(c); | ||||
|         } | ||||
|         return bitSet; | ||||
|     } | ||||
| 
 | ||||
|     private static Map<Character, DelimiterProcessor> calculateDelimiterProcessors(List<DelimiterProcessor> delimiterProcessors) { | ||||
|         Map<Character, DelimiterProcessor> map = new HashMap<>(); | ||||
|         addDelimiterProcessors(delimiterProcessors, map); | ||||
|         return map; | ||||
|     } | ||||
| 
 | ||||
|     private static void addDelimiterProcessors(Iterable<DelimiterProcessor> delimiterProcessors, Map<Character, DelimiterProcessor> map) { | ||||
|         for (DelimiterProcessor delimiterProcessor : delimiterProcessors) { | ||||
|             char opening = delimiterProcessor.getOpeningCharacter(); | ||||
|             char closing = delimiterProcessor.getClosingCharacter(); | ||||
|             if (opening == closing) { | ||||
|                 DelimiterProcessor old = map.get(opening); | ||||
|                 if (old != null && old.getOpeningCharacter() == old.getClosingCharacter()) { | ||||
|                     StaggeredDelimiterProcessor s; | ||||
|                     if (old instanceof StaggeredDelimiterProcessor) { | ||||
|                         s = (StaggeredDelimiterProcessor) old; | ||||
|                     } else { | ||||
|                         s = new StaggeredDelimiterProcessor(opening); | ||||
|                         s.add(old); | ||||
|                     } | ||||
|                     s.add(delimiterProcessor); | ||||
|                     map.put(opening, s); | ||||
|                 } else { | ||||
|                     addDelimiterProcessorForChar(opening, delimiterProcessor, map); | ||||
|                 } | ||||
|             } else { | ||||
|                 addDelimiterProcessorForChar(opening, delimiterProcessor, map); | ||||
|                 addDelimiterProcessorForChar(closing, delimiterProcessor, map); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static void addDelimiterProcessorForChar(char delimiterChar, DelimiterProcessor toAdd, Map<Character, DelimiterProcessor> delimiterProcessors) { | ||||
|         DelimiterProcessor existing = delimiterProcessors.put(delimiterChar, toAdd); | ||||
|         if (existing != null) { | ||||
|             throw new IllegalArgumentException("Delimiter processor conflict with delimiter char '" + delimiterChar + "'"); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Parse content in block into inline children, using reference map to resolve references. | ||||
|      */ | ||||
|     @Override | ||||
|     public void parse(String content, Node block) { | ||||
|         reset(content.trim()); | ||||
| 
 | ||||
|         // we still reference it | ||||
|         this.block = block; | ||||
| 
 | ||||
|         while (true) { | ||||
|             Node node = parseInline(); | ||||
|             if (node != null) { | ||||
|                 block.appendChild(node); | ||||
|             } else { | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         processDelimiters(null); | ||||
|         mergeChildTextNodes(block); | ||||
|     } | ||||
| 
 | ||||
|     private void reset(String content) { | ||||
|         this.input = content; | ||||
|         this.index = 0; | ||||
|         this.lastDelimiter = null; | ||||
|         this.lastBracket = null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Parse the next inline element in subject, advancing input index. | ||||
|      * On success, add the result to block's children and return true. | ||||
|      * On failure, return false. | ||||
|      */ | ||||
|     @Nullable | ||||
|     private Node parseInline() { | ||||
| 
 | ||||
|         final char c = peek(); | ||||
| 
 | ||||
|         if (c == '\0') { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         Node node = null; | ||||
| 
 | ||||
|         final List<InlineProcessor> inlines = this.inlineProcessors.get(c); | ||||
| 
 | ||||
|         if (inlines != null) { | ||||
|             for (InlineProcessor inline : inlines) { | ||||
|                 node = inline.parse(this); | ||||
|                 if (node != null) { | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|         } else { | ||||
|             final DelimiterProcessor delimiterProcessor = delimiterProcessors.get(c); | ||||
|             if (delimiterProcessor != null) { | ||||
|                 node = parseDelimiters(delimiterProcessor, c); | ||||
|             } else { | ||||
|                 node = parseString(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (node != null) { | ||||
|             return node; | ||||
|         } else { | ||||
|             index++; | ||||
|             // When we get here, it's only for a single special character that turned out to not have a special meaning. | ||||
|             // So we shouldn't have a single surrogate here, hence it should be ok to turn it into a String. | ||||
|             String literal = String.valueOf(c); | ||||
|             return text(literal); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * If RE matches at current index in the input, advance index and return the match; otherwise return null. | ||||
|      */ | ||||
|     @Override | ||||
|     @Nullable | ||||
|     public String match(@NonNull 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; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Text text(@NonNull String text) { | ||||
|         return new Text(text); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Text text(@NonNull String text, int beginIndex, int endIndex) { | ||||
|         return new Text(text.substring(beginIndex, endIndex)); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     @Override | ||||
|     public LinkReferenceDefinition getLinkReferenceDefinition(String label) { | ||||
|         return referencesEnabled | ||||
|                 ? inlineParserContext.getLinkReferenceDefinition(label) | ||||
|                 : null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the char at the current input index, or {@code '\0'} in case there are no more characters. | ||||
|      */ | ||||
|     @Override | ||||
|     public char peek() { | ||||
|         if (index < input.length()) { | ||||
|             return input.charAt(index); | ||||
|         } else { | ||||
|             return '\0'; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Node block() { | ||||
|         return block; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public String input() { | ||||
|         return input; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public int index() { | ||||
|         return index; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void setIndex(int index) { | ||||
|         this.index = index; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public Bracket lastBracket() { | ||||
|         return lastBracket; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public Delimiter lastDelimiter() { | ||||
|         return lastDelimiter; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void addBracket(Bracket bracket) { | ||||
|         if (lastBracket != null) { | ||||
|             lastBracket.bracketAfter = true; | ||||
|         } | ||||
|         lastBracket = bracket; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void removeLastBracket() { | ||||
|         lastBracket = lastBracket.previous; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Parse zero or more space characters, including at most one newline. | ||||
|      */ | ||||
|     @Override | ||||
|     public void spnl() { | ||||
|         match(SPNL); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Attempt to parse delimiters like emphasis, strong emphasis or custom delimiters. | ||||
|      */ | ||||
|     @Nullable | ||||
|     private Node parseDelimiters(DelimiterProcessor delimiterProcessor, char delimiterChar) { | ||||
|         DelimiterData res = scanDelimiters(delimiterProcessor, delimiterChar); | ||||
|         if (res == null) { | ||||
|             return null; | ||||
|         } | ||||
|         int length = res.count; | ||||
|         int startIndex = index; | ||||
| 
 | ||||
|         index += length; | ||||
|         Text node = text(input, startIndex, index); | ||||
| 
 | ||||
|         // Add entry to stack for this opener | ||||
|         lastDelimiter = new Delimiter(node, delimiterChar, res.canOpen, res.canClose, lastDelimiter); | ||||
|         lastDelimiter.length = length; | ||||
|         lastDelimiter.originalLength = length; | ||||
|         if (lastDelimiter.previous != null) { | ||||
|             lastDelimiter.previous.next = lastDelimiter; | ||||
|         } | ||||
| 
 | ||||
|         return node; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Attempt to parse link destination, returning the string or null if no match. | ||||
|      */ | ||||
|     @Override | ||||
|     @Nullable | ||||
|     public String parseLinkDestination() { | ||||
|         int afterDest = LinkScanner.scanLinkDestination(input, index); | ||||
|         if (afterDest == -1) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         String dest; | ||||
|         if (peek() == '<') { | ||||
|             // chop off surrounding <..>: | ||||
|             dest = input.substring(index + 1, afterDest - 1); | ||||
|         } else { | ||||
|             dest = input.substring(index, afterDest); | ||||
|         } | ||||
| 
 | ||||
|         index = afterDest; | ||||
|         return Escaping.unescapeString(dest); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Attempt to parse link title (sans quotes), returning the string or null if no match. | ||||
|      */ | ||||
|     @Override | ||||
|     @Nullable | ||||
|     public String parseLinkTitle() { | ||||
|         int afterTitle = LinkScanner.scanLinkTitle(input, index); | ||||
|         if (afterTitle == -1) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         // chop off ', " or parens | ||||
|         String title = input.substring(index + 1, afterTitle - 1); | ||||
|         index = afterTitle; | ||||
|         return Escaping.unescapeString(title); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Attempt to parse a link label, returning number of characters parsed. | ||||
|      */ | ||||
|     @Override | ||||
|     public int parseLinkLabel() { | ||||
|         if (index >= input.length() || input.charAt(index) != '[') { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         int startContent = index + 1; | ||||
|         int endContent = LinkScanner.scanLinkLabelContent(input, startContent); | ||||
|         // spec: A link label can have at most 999 characters inside the square brackets. | ||||
|         int contentLength = endContent - startContent; | ||||
|         if (endContent == -1 || contentLength > 999) { | ||||
|             return 0; | ||||
|         } | ||||
|         if (endContent >= input.length() || input.charAt(endContent) != ']') { | ||||
|             return 0; | ||||
|         } | ||||
|         index = endContent + 1; | ||||
|         return contentLength + 2; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Parse a run of ordinary characters, or a single character with a special meaning in markdown, as a plain string. | ||||
|      */ | ||||
|     private Node parseString() { | ||||
|         int begin = index; | ||||
|         int length = input.length(); | ||||
|         while (index != length) { | ||||
|             if (specialCharacters.get(input.charAt(index))) { | ||||
|                 break; | ||||
|             } | ||||
|             index++; | ||||
|         } | ||||
|         if (begin != index) { | ||||
|             return text(input, begin, index); | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Scan a sequence of characters with code delimiterChar, and return information about the number of delimiters | ||||
|      * and whether they are positioned such that they can open and/or close emphasis or strong emphasis. | ||||
|      * | ||||
|      * @return information about delimiter run, or {@code null} | ||||
|      */ | ||||
|     private DelimiterData scanDelimiters(DelimiterProcessor delimiterProcessor, char delimiterChar) { | ||||
|         int startIndex = index; | ||||
| 
 | ||||
|         int delimiterCount = 0; | ||||
|         while (peek() == delimiterChar) { | ||||
|             delimiterCount++; | ||||
|             index++; | ||||
|         } | ||||
| 
 | ||||
|         if (delimiterCount < delimiterProcessor.getMinLength()) { | ||||
|             index = startIndex; | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         String before = startIndex == 0 ? "\n" : | ||||
|                 input.substring(startIndex - 1, startIndex); | ||||
| 
 | ||||
|         char charAfter = peek(); | ||||
|         String after = charAfter == '\0' ? "\n" : | ||||
|                 String.valueOf(charAfter); | ||||
| 
 | ||||
|         // We could be more lazy here, in most cases we don't need to do every match case. | ||||
|         boolean beforeIsPunctuation = PUNCTUATION.matcher(before).matches(); | ||||
|         boolean beforeIsWhitespace = UNICODE_WHITESPACE_CHAR.matcher(before).matches(); | ||||
|         boolean afterIsPunctuation = PUNCTUATION.matcher(after).matches(); | ||||
|         boolean afterIsWhitespace = UNICODE_WHITESPACE_CHAR.matcher(after).matches(); | ||||
| 
 | ||||
|         boolean leftFlanking = !afterIsWhitespace && | ||||
|                 (!afterIsPunctuation || beforeIsWhitespace || beforeIsPunctuation); | ||||
|         boolean rightFlanking = !beforeIsWhitespace && | ||||
|                 (!beforeIsPunctuation || afterIsWhitespace || afterIsPunctuation); | ||||
|         boolean canOpen; | ||||
|         boolean canClose; | ||||
|         if (delimiterChar == '_') { | ||||
|             canOpen = leftFlanking && (!rightFlanking || beforeIsPunctuation); | ||||
|             canClose = rightFlanking && (!leftFlanking || afterIsPunctuation); | ||||
|         } else { | ||||
|             canOpen = leftFlanking && delimiterChar == delimiterProcessor.getOpeningCharacter(); | ||||
|             canClose = rightFlanking && delimiterChar == delimiterProcessor.getClosingCharacter(); | ||||
|         } | ||||
| 
 | ||||
|         index = startIndex; | ||||
|         return new DelimiterData(delimiterCount, canOpen, canClose); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public 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 | ||||
|         while (lastDelimiter != null && lastDelimiter != stackBottom) { | ||||
|             removeDelimiterKeepNode(lastDelimiter); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private 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*`. | ||||
|      */ | ||||
|     private 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`. | ||||
|      */ | ||||
|     private void removeDelimiterKeepNode(Delimiter delim) { | ||||
|         removeDelimiter(delim); | ||||
|     } | ||||
| 
 | ||||
|     private void removeDelimiter(Delimiter delim) { | ||||
|         if (delim.previous != null) { | ||||
|             delim.previous.next = delim.next; | ||||
|         } | ||||
|         if (delim.next == null) { | ||||
|             // top of stack | ||||
|             lastDelimiter = delim.previous; | ||||
|         } else { | ||||
|             delim.next.previous = delim.previous; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static class DelimiterData { | ||||
| 
 | ||||
|         final int count; | ||||
|         final boolean canClose; | ||||
|         final boolean canOpen; | ||||
| 
 | ||||
|         DelimiterData(int count, boolean canOpen, boolean canClose) { | ||||
|             this.count = count; | ||||
|             this.canOpen = canOpen; | ||||
|             this.canClose = canClose; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     static class FactoryBuilderImpl implements FactoryBuilder, FactoryBuilderNoDefaults { | ||||
| 
 | ||||
|         private final List<InlineProcessor> inlineProcessors = new ArrayList<>(3); | ||||
|         private final List<DelimiterProcessor> delimiterProcessors = new ArrayList<>(3); | ||||
|         private boolean referencesEnabled; | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public FactoryBuilder addInlineProcessor(@NonNull InlineProcessor processor) { | ||||
|             this.inlineProcessors.add(processor); | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public FactoryBuilder addDelimiterProcessor(@NonNull DelimiterProcessor processor) { | ||||
|             this.delimiterProcessors.add(processor); | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public FactoryBuilder referencesEnabled(boolean referencesEnabled) { | ||||
|             this.referencesEnabled = referencesEnabled; | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public FactoryBuilder includeDefaults() { | ||||
| 
 | ||||
|             // by default enabled | ||||
|             this.referencesEnabled = true; | ||||
| 
 | ||||
|             this.inlineProcessors.addAll(Arrays.asList( | ||||
|                     new AutolinkInlineProcessor(), | ||||
|                     new BackslashInlineProcessor(), | ||||
|                     new BackticksInlineProcessor(), | ||||
|                     new BangInlineProcessor(), | ||||
|                     new CloseBracketInlineProcessor(), | ||||
|                     new EntityInlineProcessor(), | ||||
|                     new HtmlInlineProcessor(), | ||||
|                     new NewLineInlineProcessor(), | ||||
|                     new OpenBracketInlineProcessor())); | ||||
| 
 | ||||
|             this.delimiterProcessors.addAll(Arrays.asList( | ||||
|                     new AsteriskDelimiterProcessor(), | ||||
|                     new UnderscoreDelimiterProcessor())); | ||||
| 
 | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public FactoryBuilder excludeInlineProcessor(@NonNull Class<? extends InlineProcessor> type) { | ||||
|             for (int i = 0, size = inlineProcessors.size(); i < size; i++) { | ||||
|                 if (type.equals(inlineProcessors.get(i).getClass())) { | ||||
|                     inlineProcessors.remove(i); | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public FactoryBuilder excludeDelimiterProcessor(@NonNull Class<? extends DelimiterProcessor> type) { | ||||
|             for (int i = 0, size = delimiterProcessors.size(); i < size; i++) { | ||||
|                 if (type.equals(delimiterProcessors.get(i).getClass())) { | ||||
|                     delimiterProcessors.remove(i); | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public InlineParserFactory build() { | ||||
|             return new InlineParserFactoryImpl(referencesEnabled, inlineProcessors, delimiterProcessors); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     static class InlineParserFactoryImpl implements InlineParserFactory { | ||||
| 
 | ||||
|         private final boolean referencesEnabled; | ||||
|         private final List<InlineProcessor> inlineProcessors; | ||||
|         private final List<DelimiterProcessor> delimiterProcessors; | ||||
| 
 | ||||
|         InlineParserFactoryImpl( | ||||
|                 boolean referencesEnabled, | ||||
|                 @NonNull List<InlineProcessor> inlineProcessors, | ||||
|                 @NonNull List<DelimiterProcessor> delimiterProcessors) { | ||||
|             this.referencesEnabled = referencesEnabled; | ||||
|             this.inlineProcessors = inlineProcessors; | ||||
|             this.delimiterProcessors = delimiterProcessors; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public InlineParser create(InlineParserContext inlineParserContext) { | ||||
|             final List<DelimiterProcessor> delimiterProcessors; | ||||
|             final List<DelimiterProcessor> customDelimiterProcessors = inlineParserContext.getCustomDelimiterProcessors(); | ||||
|             final int size = customDelimiterProcessors != null | ||||
|                     ? customDelimiterProcessors.size() | ||||
|                     : 0; | ||||
|             if (size > 0) { | ||||
|                 delimiterProcessors = new ArrayList<>(size + this.delimiterProcessors.size()); | ||||
|                 delimiterProcessors.addAll(this.delimiterProcessors); | ||||
|                 delimiterProcessors.addAll(customDelimiterProcessors); | ||||
|             } else { | ||||
|                 delimiterProcessors = this.delimiterProcessors; | ||||
|             } | ||||
|             return new MarkwonInlineParser( | ||||
|                     inlineParserContext, | ||||
|                     referencesEnabled, | ||||
|                     inlineProcessors, | ||||
|                     delimiterProcessors); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,64 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.internal.Delimiter; | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.LinkReferenceDefinition; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| import java.util.Map; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| public interface MarkwonInlineParserContext { | ||||
| 
 | ||||
|     @NonNull | ||||
|     Node block(); | ||||
| 
 | ||||
|     @NonNull | ||||
|     String input(); | ||||
| 
 | ||||
|     int index(); | ||||
| 
 | ||||
|     void setIndex(int index); | ||||
| 
 | ||||
|     Bracket lastBracket(); | ||||
| 
 | ||||
|     Delimiter lastDelimiter(); | ||||
| 
 | ||||
|     void addBracket(Bracket bracket); | ||||
| 
 | ||||
|     void removeLastBracket(); | ||||
| 
 | ||||
|     void spnl(); | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the char at the current input index, or {@code '\0'} in case there are no more characters. | ||||
|      */ | ||||
|     char peek(); | ||||
| 
 | ||||
|     @Nullable | ||||
|     String match(@NonNull Pattern re); | ||||
| 
 | ||||
|     @NonNull | ||||
|     Text text(@NonNull String text); | ||||
| 
 | ||||
|     @NonNull | ||||
|     Text text(@NonNull String text, int beginIndex, int endIndex); | ||||
| 
 | ||||
|     @Nullable | ||||
|     LinkReferenceDefinition getLinkReferenceDefinition(String label); | ||||
| 
 | ||||
|     @Nullable | ||||
|     String parseLinkDestination(); | ||||
| 
 | ||||
|     @Nullable | ||||
|     String parseLinkTitle(); | ||||
| 
 | ||||
|     int parseLinkLabel(); | ||||
| 
 | ||||
|     void processDelimiters(Delimiter stackBottom); | ||||
| } | ||||
| @ -0,0 +1,48 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.node.HardLineBreak; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.SoftLineBreak; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class NewLineInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|     private static final Pattern FINAL_SPACE = Pattern.compile(" *$"); | ||||
| 
 | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '\n'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         index++; // assume we're at a \n | ||||
| 
 | ||||
|         final Node previous = block.getLastChild(); | ||||
| 
 | ||||
|         // Check previous text for trailing spaces. | ||||
|         // The "endsWith" is an optimization to avoid an RE match in the common case. | ||||
|         if (previous instanceof Text && ((Text) previous).getLiteral().endsWith(" ")) { | ||||
|             Text text = (Text) previous; | ||||
|             String literal = text.getLiteral(); | ||||
|             Matcher matcher = FINAL_SPACE.matcher(literal); | ||||
|             int spaces = matcher.find() ? matcher.end() - matcher.start() : 0; | ||||
|             if (spaces > 0) { | ||||
|                 text.setLiteral(literal.substring(0, literal.length() - spaces)); | ||||
|             } | ||||
|             if (spaces >= 2) { | ||||
|                 return new HardLineBreak(); | ||||
|             } else { | ||||
|                 return new SoftLineBreak(); | ||||
|             } | ||||
|         } else { | ||||
|             return new SoftLineBreak(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,30 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.internal.Bracket; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.node.Text; | ||||
| 
 | ||||
| /** | ||||
|  * Parses markdown links {@code [link](#href)} | ||||
|  * | ||||
|  * @since 4.2.0 | ||||
|  */ | ||||
| public class OpenBracketInlineProcessor extends InlineProcessor { | ||||
|     @Override | ||||
|     public char specialCharacter() { | ||||
|         return '['; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected Node parse() { | ||||
|         int startIndex = index; | ||||
|         index++; | ||||
| 
 | ||||
|         Text node = text("["); | ||||
| 
 | ||||
|         // Add entry to stack for this opener | ||||
|         addBracket(Bracket.link(node, startIndex, lastBracket(), lastDelimiter())); | ||||
| 
 | ||||
|         return node; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,75 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.node.Text; | ||||
| import org.commonmark.parser.delimiter.DelimiterProcessor; | ||||
| import org.commonmark.parser.delimiter.DelimiterRun; | ||||
| 
 | ||||
| import java.util.LinkedList; | ||||
| import java.util.ListIterator; | ||||
| 
 | ||||
| class StaggeredDelimiterProcessor implements DelimiterProcessor { | ||||
| 
 | ||||
|     private final char delim; | ||||
|     private int minLength = 0; | ||||
|     private LinkedList<DelimiterProcessor> processors = new LinkedList<>(); // in reverse getMinLength order | ||||
| 
 | ||||
|     StaggeredDelimiterProcessor(char delim) { | ||||
|         this.delim = delim; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public char getOpeningCharacter() { | ||||
|         return delim; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public char getClosingCharacter() { | ||||
|         return delim; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public int getMinLength() { | ||||
|         return minLength; | ||||
|     } | ||||
| 
 | ||||
|     void add(DelimiterProcessor dp) { | ||||
|         final int len = dp.getMinLength(); | ||||
|         ListIterator<DelimiterProcessor> it = processors.listIterator(); | ||||
|         boolean added = false; | ||||
|         while (it.hasNext()) { | ||||
|             DelimiterProcessor p = it.next(); | ||||
|             int pLen = p.getMinLength(); | ||||
|             if (len > pLen) { | ||||
|                 it.previous(); | ||||
|                 it.add(dp); | ||||
|                 added = true; | ||||
|                 break; | ||||
|             } else if (len == pLen) { | ||||
|                 throw new IllegalArgumentException("Cannot add two delimiter processors for char '" + delim + "' and minimum length " + len); | ||||
|             } | ||||
|         } | ||||
|         if (!added) { | ||||
|             processors.add(dp); | ||||
|             this.minLength = len; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private DelimiterProcessor findProcessor(int len) { | ||||
|         for (DelimiterProcessor p : processors) { | ||||
|             if (p.getMinLength() <= len) { | ||||
|                 return p; | ||||
|             } | ||||
|         } | ||||
|         return processors.getFirst(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public int getDelimiterUse(DelimiterRun opener, DelimiterRun closer) { | ||||
|         return findProcessor(opener.length()).getDelimiterUse(opener, closer); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void process(Text opener, Text closer, int delimiterUse) { | ||||
|         findProcessor(delimiterUse).process(opener, closer, delimiterUse); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,25 @@ | ||||
| package io.noties.markwon.inlineparser; | ||||
| 
 | ||||
| import org.commonmark.parser.Parser; | ||||
| import org.commonmark.renderer.html.HtmlRenderer; | ||||
| import org.commonmark.testutil.SpecTestCase; | ||||
| import org.commonmark.testutil.example.Example; | ||||
| 
 | ||||
| public class InlineParserSpecTest extends SpecTestCase { | ||||
| 
 | ||||
|     private static final Parser PARSER = Parser.builder() | ||||
|             .inlineParserFactory(MarkwonInlineParser.factoryBuilder().build()) | ||||
|             .build(); | ||||
| 
 | ||||
|     // The spec says URL-escaping is optional, but the examples assume that it's enabled. | ||||
|     private static final HtmlRenderer RENDERER = HtmlRenderer.builder().percentEncodeUrls(true).build(); | ||||
| 
 | ||||
|     public InlineParserSpecTest(Example example) { | ||||
|         super(example); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected String render(String source) { | ||||
|         return RENDERER.render(PARSER.parse(source)); | ||||
|     } | ||||
| } | ||||
| @ -1,18 +1,24 @@ | ||||
| package io.noties.markwon.linkify; | ||||
| 
 | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.style.URLSpan; | ||||
| import android.text.util.Linkify; | ||||
| 
 | ||||
| import androidx.annotation.IntDef; | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import org.commonmark.node.Link; | ||||
| 
 | ||||
| import java.lang.annotation.Retention; | ||||
| import java.lang.annotation.RetentionPolicy; | ||||
| 
 | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.RenderProps; | ||||
| import io.noties.markwon.SpanFactory; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.core.CorePlugin; | ||||
| import io.noties.markwon.core.CoreProps; | ||||
| 
 | ||||
| public class LinkifyPlugin extends AbstractMarkwonPlugin { | ||||
| 
 | ||||
| @ -55,34 +61,42 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin { | ||||
|     private static class LinkifyTextAddedListener implements CorePlugin.OnTextAddedListener { | ||||
| 
 | ||||
|         private final int mask; | ||||
|         private final SpannableStringBuilder builder; | ||||
| 
 | ||||
|         LinkifyTextAddedListener(int mask) { | ||||
|             this.mask = mask; | ||||
|             this.builder = new SpannableStringBuilder(); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void onTextAdded(@NonNull MarkwonVisitor visitor, @NonNull String text, int start) { | ||||
| 
 | ||||
|             // clear previous state | ||||
|             builder.clear(); | ||||
|             builder.clearSpans(); | ||||
|             // @since 4.2.0 obtain span factory for links | ||||
|             //  we will be using the link that is used by markdown (instead of directly applying URLSpan) | ||||
|             final SpanFactory spanFactory = visitor.configuration().spansFactory().get(Link.class); | ||||
|             if (spanFactory == null) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // append text to process | ||||
|             builder.append(text); | ||||
|             // @since 4.2.0 we no longer re-use builder (thread safety achieved for | ||||
|             //  render calls from different threads and ... better performance) | ||||
|             final SpannableStringBuilder builder = new SpannableStringBuilder(text); | ||||
| 
 | ||||
|             if (Linkify.addLinks(builder, mask)) { | ||||
|                 final Object[] spans = builder.getSpans(0, builder.length(), Object.class); | ||||
|                 // target URL span specifically | ||||
|                 final URLSpan[] spans = builder.getSpans(0, builder.length(), URLSpan.class); | ||||
|                 if (spans != null | ||||
|                         && spans.length > 0) { | ||||
| 
 | ||||
|                     final RenderProps renderProps = visitor.renderProps(); | ||||
|                     final SpannableBuilder spannableBuilder = visitor.builder(); | ||||
|                     for (Object span : spans) { | ||||
|                         spannableBuilder.setSpan( | ||||
|                                 span, | ||||
| 
 | ||||
|                     for (URLSpan span : spans) { | ||||
|                         CoreProps.LINK_DESTINATION.set(renderProps, span.getURL()); | ||||
|                         SpannableBuilder.setSpans( | ||||
|                                 spannableBuilder, | ||||
|                                 spanFactory.getSpans(visitor.configuration(), renderProps), | ||||
|                                 start + builder.getSpanStart(span), | ||||
|                                 start + builder.getSpanEnd(span), | ||||
|                                 builder.getSpanFlags(span)); | ||||
|                                 start + builder.getSpanEnd(span) | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
| @ -34,16 +34,19 @@ android { | ||||
| dependencies { | ||||
| 
 | ||||
|     implementation project(':markwon-core') | ||||
|     implementation project(':markwon-editor') | ||||
|     implementation project(':markwon-ext-latex') | ||||
|     implementation project(':markwon-ext-strikethrough') | ||||
|     implementation project(':markwon-ext-tables') | ||||
|     implementation project(':markwon-ext-tasklist') | ||||
|     implementation project(':markwon-html') | ||||
|     implementation project(':markwon-image') | ||||
|     implementation project(':markwon-syntax-highlight') | ||||
|     implementation project(':markwon-inline-parser') | ||||
|     implementation project(':markwon-linkify') | ||||
|     implementation project(':markwon-recycler') | ||||
|     implementation project(':markwon-recycler-table') | ||||
|     implementation project(':markwon-simple-ext') | ||||
|     implementation project(':markwon-syntax-highlight') | ||||
| 
 | ||||
|     implementation project(':markwon-image-picasso') | ||||
| 
 | ||||
|  | ||||
| @ -29,6 +29,12 @@ | ||||
|         <activity android:name=".customextension2.CustomExtensionActivity2" /> | ||||
|         <activity android:name=".precomputed.PrecomputedActivity" /> | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".editor.EditorActivity" | ||||
|             android:windowSoftInputMode="adjustResize" /> | ||||
| 
 | ||||
|         <activity android:name=".inlineparser.InlineParserActivity" /> | ||||
| 
 | ||||
|     </application> | ||||
| 
 | ||||
| </manifest> | ||||
| @ -22,7 +22,9 @@ import io.noties.markwon.sample.basicplugins.BasicPluginsActivity; | ||||
| import io.noties.markwon.sample.core.CoreActivity; | ||||
| import io.noties.markwon.sample.customextension.CustomExtensionActivity; | ||||
| import io.noties.markwon.sample.customextension2.CustomExtensionActivity2; | ||||
| import io.noties.markwon.sample.editor.EditorActivity; | ||||
| import io.noties.markwon.sample.html.HtmlActivity; | ||||
| import io.noties.markwon.sample.inlineparser.InlineParserActivity; | ||||
| import io.noties.markwon.sample.latex.LatexActivity; | ||||
| import io.noties.markwon.sample.precomputed.PrecomputedActivity; | ||||
| import io.noties.markwon.sample.recycler.RecyclerActivity; | ||||
| @ -117,6 +119,14 @@ public class MainActivity extends Activity { | ||||
|                 activity = PrecomputedActivity.class; | ||||
|                 break; | ||||
| 
 | ||||
|             case EDITOR: | ||||
|                 activity = EditorActivity.class; | ||||
|                 break; | ||||
| 
 | ||||
|             case INLINE_PARSER: | ||||
|                 activity = InlineParserActivity.class; | ||||
|                 break; | ||||
| 
 | ||||
|             default: | ||||
|                 throw new IllegalStateException("No Activity is associated with sample-item: " + item); | ||||
|         } | ||||
|  | ||||
| @ -21,7 +21,11 @@ public enum Sample { | ||||
| 
 | ||||
|     CUSTOM_EXTENSION_2(R.string.sample_custom_extension_2), | ||||
| 
 | ||||
|     PRECOMPUTED_TEXT(R.string.sample_precomputed_text); | ||||
|     PRECOMPUTED_TEXT(R.string.sample_precomputed_text), | ||||
| 
 | ||||
|     EDITOR(R.string.sample_editor), | ||||
| 
 | ||||
|     INLINE_PARSER(R.string.sample_inline_parser); | ||||
| 
 | ||||
|     private final int textResId; | ||||
| 
 | ||||
|  | ||||
| @ -8,6 +8,9 @@ import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.node.Link; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.parser.InlineParserFactory; | ||||
| import org.commonmark.parser.Parser; | ||||
| 
 | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
| @ -20,6 +23,8 @@ import io.noties.markwon.RenderProps; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.core.CorePlugin; | ||||
| import io.noties.markwon.core.CoreProps; | ||||
| import io.noties.markwon.inlineparser.InlineProcessor; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParser; | ||||
| import io.noties.markwon.sample.R; | ||||
| 
 | ||||
| public class CustomExtensionActivity2 extends Activity { | ||||
| @ -35,6 +40,20 @@ public class CustomExtensionActivity2 extends Activity { | ||||
|         // * `#1` - an issue or a pull request | ||||
|         // * `@user` link to a user | ||||
| 
 | ||||
| 
 | ||||
|         final String md = "# Custom Extension 2\n" + | ||||
|                 "\n" + | ||||
|                 "This is an issue #1\n" + | ||||
|                 "Done by @noties"; | ||||
| 
 | ||||
| 
 | ||||
| //        inline_parsing(textView, md); | ||||
| 
 | ||||
|         text_added(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void text_added(@NonNull TextView textView, @NonNull String md) { | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
| @ -45,14 +64,83 @@ public class CustomExtensionActivity2 extends Activity { | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final String md = "# Custom Extension 2\n" + | ||||
|                 "\n" + | ||||
|                 "This is an issue #1\n" + | ||||
|                 "Done by @noties"; | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void inline_parsing(@NonNull TextView textView, @NonNull String md) { | ||||
| 
 | ||||
|         final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() | ||||
|                 // include all current defaults (otherwise will be empty - contain only our inline-processors) | ||||
|                 //  included by default, to create factory-builder without defaults call `factoryBuilderNoDefaults` | ||||
| //                .includeDefaults() | ||||
|                 .addInlineProcessor(new IssueInlineProcessor()) | ||||
|                 .addInlineProcessor(new UserInlineProcessor()) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                         builder.inlineParserFactory(inlineParserFactory); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private static class IssueInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|         private static final Pattern RE = Pattern.compile("\\d+"); | ||||
| 
 | ||||
|         @Override | ||||
|         public char specialCharacter() { | ||||
|             return '#'; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         protected Node parse() { | ||||
|             final String id = match(RE); | ||||
|             if (id != null) { | ||||
|                 final Link link = new Link(createIssueOrPullRequestLinkDestination(id), null); | ||||
|                 link.appendChild(text("#" + id)); | ||||
|                 return link; | ||||
|             } | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         private static String createIssueOrPullRequestLinkDestination(@NonNull String id) { | ||||
|             return "https://github.com/noties/Markwon/issues/" + id; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static class UserInlineProcessor extends InlineProcessor { | ||||
| 
 | ||||
|         private static final Pattern RE = Pattern.compile("\\w+"); | ||||
| 
 | ||||
|         @Override | ||||
|         public char specialCharacter() { | ||||
|             return '@'; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         protected Node parse() { | ||||
|             final String user = match(RE); | ||||
|             if (user != null) { | ||||
|                 final Link link = new Link(createUserLinkDestination(user), null); | ||||
|                 link.appendChild(text("@" + user)); | ||||
|                 return link; | ||||
|             } | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         private static String createUserLinkDestination(@NonNull String user) { | ||||
|             return "https://github.com/" + user; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static class GithubLinkifyRegexTextAddedListener implements CorePlugin.OnTextAddedListener { | ||||
| 
 | ||||
|         private static final Pattern PATTERN = Pattern.compile("((#\\d+)|(@\\w+))", Pattern.MULTILINE); | ||||
|  | ||||
| @ -0,0 +1,50 @@ | ||||
| package io.noties.markwon.sample.editor; | ||||
| 
 | ||||
| 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; | ||||
| 
 | ||||
| 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; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,54 @@ | ||||
| package io.noties.markwon.sample.editor; | ||||
| 
 | ||||
| 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; | ||||
| 
 | ||||
| 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 MarkwonEditorUtils.Match 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; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,330 @@ | ||||
| package io.noties.markwon.sample.editor; | ||||
| 
 | ||||
| import android.app.Activity; | ||||
| import android.os.Bundle; | ||||
| import android.text.Editable; | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.Spanned; | ||||
| import android.text.TextPaint; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.text.style.ForegroundColorSpan; | ||||
| import android.text.style.MetricAffectingSpan; | ||||
| import android.text.style.StrikethroughSpan; | ||||
| import android.view.View; | ||||
| import android.widget.Button; | ||||
| import android.widget.EditText; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.parser.InlineParserFactory; | ||||
| import org.commonmark.parser.Parser; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.Executors; | ||||
| 
 | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.core.spans.EmphasisSpan; | ||||
| import io.noties.markwon.core.spans.StrongEmphasisSpan; | ||||
| import io.noties.markwon.editor.AbstractEditHandler; | ||||
| import io.noties.markwon.editor.MarkwonEditor; | ||||
| import io.noties.markwon.editor.MarkwonEditorTextWatcher; | ||||
| import io.noties.markwon.editor.MarkwonEditorUtils; | ||||
| import io.noties.markwon.editor.PersistedSpans; | ||||
| 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.inlineparser.BangInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.EntityInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.HtmlInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParser; | ||||
| import io.noties.markwon.linkify.LinkifyPlugin; | ||||
| import io.noties.markwon.sample.R; | ||||
| 
 | ||||
| public class EditorActivity extends Activity { | ||||
| 
 | ||||
|     private EditText editText; | ||||
| 
 | ||||
|     @Override | ||||
|     public void onCreate(@Nullable Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
|         setContentView(R.layout.activity_editor); | ||||
| 
 | ||||
|         this.editText = findViewById(R.id.edit_text); | ||||
|         initBottomBar(); | ||||
| 
 | ||||
| //        simple_process(); | ||||
| 
 | ||||
| //        simple_pre_render(); | ||||
| 
 | ||||
| //        custom_punctuation_span(); | ||||
| 
 | ||||
| //        additional_edit_span(); | ||||
| 
 | ||||
| //        additional_plugins(); | ||||
| 
 | ||||
|         multiple_edit_spans(); | ||||
|     } | ||||
| 
 | ||||
|     private void simple_process() { | ||||
|         // Process highlight in-place (right after text has changed) | ||||
| 
 | ||||
|         // obtain Markwon instance | ||||
|         final Markwon markwon = Markwon.create(this); | ||||
| 
 | ||||
|         // create editor | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
| 
 | ||||
|         // set edit listener | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|     } | ||||
| 
 | ||||
|     private void simple_pre_render() { | ||||
|         // Process highlight in background thread | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.create(this); | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
| 
 | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( | ||||
|                 editor, | ||||
|                 Executors.newCachedThreadPool(), | ||||
|                 editText)); | ||||
|     } | ||||
| 
 | ||||
|     private void custom_punctuation_span() { | ||||
|         // Use own punctuation span | ||||
| 
 | ||||
|         final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this)) | ||||
|                 .punctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new) | ||||
|                 .build(); | ||||
| 
 | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|     } | ||||
| 
 | ||||
|     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); | ||||
|                     } | ||||
| 
 | ||||
|                     @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(); | ||||
| 
 | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|     } | ||||
| 
 | ||||
|     private void additional_plugins() { | ||||
|         // As highlight works based on text-diff, everything that is present in input, | ||||
|         // but missing in resulting markdown is considered to be punctuation, this is why | ||||
|         // additional plugins do not need special handling | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(StrikethroughPlugin.create()) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
| 
 | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|     } | ||||
| 
 | ||||
|     private void multiple_edit_spans() { | ||||
| 
 | ||||
|         // for links to be clickable | ||||
|         editText.setMovementMethod(LinkMovementMethod.getInstance()); | ||||
| 
 | ||||
|         final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() | ||||
|                 // no inline images will be parsed | ||||
|                 .excludeInlineProcessor(BangInlineProcessor.class) | ||||
|                 // no html tags will be parsed | ||||
|                 .excludeInlineProcessor(HtmlInlineProcessor.class) | ||||
|                 // no entities will be parsed (aka `&` etc) | ||||
|                 .excludeInlineProcessor(EntityInlineProcessor.class) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(StrikethroughPlugin.create()) | ||||
|                 .usePlugin(LinkifyPlugin.create()) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
| 
 | ||||
|                         // disable all commonmark-java blocks, only inlines will be parsed | ||||
| //                        builder.enabledBlockTypes(Collections.emptySet()); | ||||
| 
 | ||||
|                         builder.inlineParserFactory(inlineParserFactory); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); | ||||
| 
 | ||||
|         final MarkwonEditor editor = MarkwonEditor.builder(markwon) | ||||
|                 .useEditHandler(new EmphasisEditHandler()) | ||||
|                 .useEditHandler(new StrongEmphasisEditHandler()) | ||||
|                 .useEditHandler(new StrikethroughEditHandler()) | ||||
|                 .useEditHandler(new CodeEditHandler()) | ||||
|                 .useEditHandler(new BlockQuoteEditHandler()) | ||||
|                 .useEditHandler(new LinkEditHandler(onClick)) | ||||
|                 .build(); | ||||
| 
 | ||||
| //        editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( | ||||
|                 editor, Executors.newSingleThreadExecutor(), editText)); | ||||
|     } | ||||
| 
 | ||||
|     private void initBottomBar() { | ||||
|         // all except block-quote wraps if have selection, or inserts at current cursor position | ||||
| 
 | ||||
|         final Button bold = findViewById(R.id.bold); | ||||
|         final Button italic = findViewById(R.id.italic); | ||||
|         final Button strike = findViewById(R.id.strike); | ||||
|         final Button quote = findViewById(R.id.quote); | ||||
|         final Button code = findViewById(R.id.code); | ||||
| 
 | ||||
|         addSpan(bold, new StrongEmphasisSpan()); | ||||
|         addSpan(italic, new EmphasisSpan()); | ||||
|         addSpan(strike, new StrikethroughSpan()); | ||||
| 
 | ||||
|         bold.setOnClickListener(new InsertOrWrapClickListener(editText, "**")); | ||||
|         italic.setOnClickListener(new InsertOrWrapClickListener(editText, "_")); | ||||
|         strike.setOnClickListener(new InsertOrWrapClickListener(editText, "~~")); | ||||
|         code.setOnClickListener(new InsertOrWrapClickListener(editText, "`")); | ||||
| 
 | ||||
|         quote.setOnClickListener(v -> { | ||||
| 
 | ||||
|             final int start = editText.getSelectionStart(); | ||||
|             final int end = editText.getSelectionEnd(); | ||||
| 
 | ||||
|             if (start < 0) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (start == end) { | ||||
|                 editText.getText().insert(start, "> "); | ||||
|             } else { | ||||
|                 // wrap the whole selected area in a quote | ||||
|                 final List<Integer> newLines = new ArrayList<>(3); | ||||
|                 newLines.add(start); | ||||
| 
 | ||||
|                 final String text = editText.getText().subSequence(start, end).toString(); | ||||
|                 int index = text.indexOf('\n'); | ||||
|                 while (index != -1) { | ||||
|                     newLines.add(start + index + 1); | ||||
|                     index = text.indexOf('\n', index + 1); | ||||
|                 } | ||||
| 
 | ||||
|                 for (int i = newLines.size() - 1; i >= 0; i--) { | ||||
|                     editText.getText().insert(newLines.get(i), "> "); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private static void addSpan(@NonNull TextView textView, Object... spans) { | ||||
|         final SpannableStringBuilder builder = new SpannableStringBuilder(textView.getText()); | ||||
|         final int end = builder.length(); | ||||
|         for (Object span : spans) { | ||||
|             builder.setSpan(span, 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); | ||||
|         } | ||||
|         textView.setText(builder); | ||||
|     } | ||||
| 
 | ||||
|     private static class InsertOrWrapClickListener implements View.OnClickListener { | ||||
| 
 | ||||
|         private final EditText editText; | ||||
|         private final String text; | ||||
| 
 | ||||
|         InsertOrWrapClickListener(@NonNull EditText editText, @NonNull String text) { | ||||
|             this.editText = editText; | ||||
|             this.text = text; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void onClick(View v) { | ||||
|             final int start = editText.getSelectionStart(); | ||||
|             final int end = editText.getSelectionEnd(); | ||||
| 
 | ||||
|             if (start < 0) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (start == end) { | ||||
|                 // insert at current position | ||||
|                 editText.getText().insert(start, text); | ||||
|             } else { | ||||
|                 editText.getText().insert(end, text); | ||||
|                 editText.getText().insert(start, text); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static class CustomPunctuationSpan extends ForegroundColorSpan { | ||||
|         CustomPunctuationSpan() { | ||||
|             super(0xFFFF0000); // RED | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static class Bold extends MetricAffectingSpan { | ||||
|         public Bold() { | ||||
|             super(); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void updateDrawState(TextPaint tp) { | ||||
|             update(tp); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void updateMeasureState(@NonNull TextPaint textPaint) { | ||||
|             update(textPaint); | ||||
|         } | ||||
| 
 | ||||
|         private void update(@NonNull TextPaint paint) { | ||||
|             paint.setFakeBoldText(true); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,86 @@ | ||||
| package io.noties.markwon.sample.editor; | ||||
| 
 | ||||
| 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; | ||||
| 
 | ||||
| class LinkEditHandler extends AbstractEditHandler<LinkSpan> { | ||||
| 
 | ||||
|     interface OnClick { | ||||
|         void onClick(@NonNull View widget, @NonNull String link); | ||||
|     } | ||||
| 
 | ||||
|     private final OnClick onClick; | ||||
| 
 | ||||
|     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 EditLinkSpan 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); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,45 @@ | ||||
| package io.noties.markwon.sample.editor; | ||||
| 
 | ||||
| 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; | ||||
| 
 | ||||
| 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 MarkwonEditorUtils.Match 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; | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,118 @@ | ||||
| package io.noties.markwon.sample.inlineparser; | ||||
| 
 | ||||
| import android.app.Activity; | ||||
| import android.os.Bundle; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.node.Block; | ||||
| import org.commonmark.node.BlockQuote; | ||||
| import org.commonmark.node.Heading; | ||||
| import org.commonmark.node.HtmlBlock; | ||||
| import org.commonmark.node.ListBlock; | ||||
| import org.commonmark.node.ThematicBreak; | ||||
| import org.commonmark.parser.InlineParserFactory; | ||||
| import org.commonmark.parser.Parser; | ||||
| 
 | ||||
| import java.util.Arrays; | ||||
| import java.util.HashSet; | ||||
| import java.util.Set; | ||||
| 
 | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.inlineparser.BackticksInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParser; | ||||
| import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; | ||||
| import io.noties.markwon.sample.R; | ||||
| 
 | ||||
| public class InlineParserActivity extends Activity { | ||||
| 
 | ||||
|     private TextView textView; | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onCreate(@Nullable Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
|         setContentView(R.layout.activity_text_view); | ||||
| 
 | ||||
|         this.textView = findViewById(R.id.text_view); | ||||
| 
 | ||||
| //        links_only(); | ||||
| 
 | ||||
|         disable_code(); | ||||
|     } | ||||
| 
 | ||||
|     private void links_only() { | ||||
| 
 | ||||
|         // create an inline-parser-factory that will _ONLY_ parse links | ||||
|         //  this would mean: | ||||
|         //  * no emphasises (strong and regular aka bold and italics), | ||||
|         //  * no images, | ||||
|         //  * no code, | ||||
|         //  * no HTML entities (&) | ||||
|         //  * no HTML tags | ||||
|         // markdown blocks are still parsed | ||||
|         final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilderNoDefaults() | ||||
|                 .referencesEnabled(true) | ||||
|                 .addInlineProcessor(new OpenBracketInlineProcessor()) | ||||
|                 .addInlineProcessor(new CloseBracketInlineProcessor()) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                         builder.inlineParserFactory(inlineParserFactory); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         // note that image is considered a link now | ||||
|         final String md = "**bold_bold-italic_** <u>html-u</u>, [link](#)  `code`"; | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void disable_code() { | ||||
|         // parses all as usual, but ignores code (inline and block) | ||||
| 
 | ||||
|         final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() | ||||
|                 .excludeInlineProcessor(BackticksInlineProcessor.class) | ||||
|                 .build(); | ||||
| 
 | ||||
|         // unfortunately there is no _exclude_ method for parser-builder | ||||
|         final Set<Class<? extends Block>> enabledBlocks = new HashSet<Class<? extends Block>>() {{ | ||||
|             // IndentedCodeBlock.class and FencedCodeBlock.class are missing | ||||
|             // this is full list (including above) that can be passed to `enabledBlockTypes` method | ||||
|             addAll(Arrays.asList( | ||||
|                     BlockQuote.class, | ||||
|                     Heading.class, | ||||
|                     HtmlBlock.class, | ||||
|                     ThematicBreak.class, | ||||
|                     ListBlock.class)); | ||||
|         }}; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                         builder | ||||
|                                 .inlineParserFactory(inlineParserFactory) | ||||
|                                 .enabledBlockTypes(enabledBlocks); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final String md = "# Head!\n\n" + | ||||
|                 "* one\n" + | ||||
|                 "+ two\n\n" + | ||||
|                 "and **bold** to `you`!\n\n" + | ||||
|                 "> a quote _em_\n\n" + | ||||
|                 "```java\n" + | ||||
|                 "final int i = 0;\n" + | ||||
|                 "```\n\n" + | ||||
|                 "**Good day!**"; | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| } | ||||
| @ -82,6 +82,7 @@ public class RecyclerActivity extends Activity { | ||||
| //                })) | ||||
|                 .usePlugin(PicassoImagesPlugin.create(context)) | ||||
| //                .usePlugin(GlideImagesPlugin.create(context)) | ||||
| //                .usePlugin(CoilImagesPlugin.create(context)) | ||||
|                 // important to use TableEntryPlugin instead of TablePlugin | ||||
|                 .usePlugin(TableEntryPlugin.create(context)) | ||||
|                 .usePlugin(HtmlPlugin.create()) | ||||
|  | ||||
							
								
								
									
										72
									
								
								sample/src/main/res/layout/activity_editor.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								sample/src/main/res/layout/activity_editor.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:clipToPadding="false" | ||||
|     android:orientation="vertical" | ||||
|     android:padding="8dip"> | ||||
| 
 | ||||
|     <FrameLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="0px" | ||||
|         android:layout_weight="1"> | ||||
| 
 | ||||
|         <EditText | ||||
|             android:id="@+id/edit_text" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:autofillHints="none" | ||||
|             android:hint="Markdown..." | ||||
|             android:inputType="text|textLongMessage|textMultiLine" | ||||
|             android:maxLines="100" /> | ||||
| 
 | ||||
|     </FrameLayout> | ||||
| 
 | ||||
|     <LinearLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:orientation="horizontal"> | ||||
| 
 | ||||
|         <Button | ||||
|             android:id="@+id/bold" | ||||
|             android:layout_width="0px" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_weight="1" | ||||
|             android:text="B" | ||||
|             android:typeface="monospace" /> | ||||
| 
 | ||||
|         <Button | ||||
|             android:id="@+id/italic" | ||||
|             android:layout_width="0px" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_weight="1" | ||||
|             android:text="I" | ||||
|             android:typeface="monospace" /> | ||||
| 
 | ||||
|         <Button | ||||
|             android:id="@+id/strike" | ||||
|             android:layout_width="0px" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_weight="1" | ||||
|             android:text="S" | ||||
|             android:typeface="monospace" /> | ||||
| 
 | ||||
|         <Button | ||||
|             android:id="@+id/quote" | ||||
|             android:layout_width="0px" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_weight="1" | ||||
|             android:text=">" | ||||
|             android:typeface="monospace" /> | ||||
| 
 | ||||
|         <Button | ||||
|             android:id="@+id/code" | ||||
|             android:layout_width="0px" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_weight="1" | ||||
|             android:text="`" | ||||
|             android:typeface="monospace" /> | ||||
| 
 | ||||
|     </LinearLayout> | ||||
| 
 | ||||
| </LinearLayout> | ||||
| @ -25,4 +25,8 @@ | ||||
| 
 | ||||
|     <string name="sample_precomputed_text"># \# PrecomputedText\n\nUsage of TextSetter and PrecomputedTextCompat</string> | ||||
| 
 | ||||
|     <string name="sample_editor"># \# Editor\n\n`MarkwonEditor` sample usage to highlight user input in EditText</string> | ||||
| 
 | ||||
|     <string name="sample_inline_parser"># \# Inline Parser\n\nUsage of custom inline parser</string> | ||||
| 
 | ||||
| </resources> | ||||
| @ -1,14 +1,17 @@ | ||||
| rootProject.name = 'MarkwonProject' | ||||
| include ':app', ':sample', | ||||
|         ':markwon-core', | ||||
|         ':markwon-editor', | ||||
|         ':markwon-ext-latex', | ||||
|         ':markwon-ext-strikethrough', | ||||
|         ':markwon-ext-tables', | ||||
|         ':markwon-ext-tasklist', | ||||
|         ':markwon-html', | ||||
|         ':markwon-image', | ||||
|         ':markwon-image-coil', | ||||
|         ':markwon-image-glide', | ||||
|         ':markwon-image-picasso', | ||||
|         ':markwon-inline-parser', | ||||
|         ':markwon-linkify', | ||||
|         ':markwon-recycler', | ||||
|         ':markwon-recycler-table', | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Dimitry
						Dimitry