commit
835b383a81
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