Merge pull request #175 from noties/v4.2.0

V4.2.0
This commit is contained in:
Dimitry 2019-11-15 17:49:05 +03:00 committed by GitHub
commit b844f4db6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 7329 additions and 59 deletions

View File

@ -15,7 +15,7 @@ jobs:
with:
java-version: 1.8
- name: Build with Gradle
run: ./gradlew build
run: ./gradlew build -Prelease
deploy:
needs: build

View File

@ -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])

View File

@ -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

View File

@ -17,13 +17,6 @@ android {
lintOptions {
abortOnError false
}
buildTypes {
debug {
minifyEnabled false
proguardFile 'proguard.pro'
}
}
}
dependencies {

View File

@ -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.&registerArtifact

View File

@ -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 };

View File

@ -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/',

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

View File

@ -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 (`*`, `_`)

View 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();
```

View 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()
```

View 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` (`<` =&gt; `<me@mydoma.in>`)
* `BackslashInlineProcessor` (`\\`)
* `BackticksInlineProcessor` (<code>&#96;</code> =&gt; <code>&#96;code&#96;</code>)
* `BangInlineProcessor` (`!` =&gt; `![alt](#src)`)
* `CloseBracketInlineProcessor` (`]` =&gt; `[link](#href)`, `![alt](#src)`)
* `EntityInlineProcessor` (`&` =&gt; `&amp;`)
* `HtmlInlineProcessor` (`<` =&gt; `<html></html>`)
* `NewLineInlineProcessor` (`\n`)
* `OpenBracketInlineProcessor` (`[` =&gt; `[link](#href)`)

View File

@ -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

View File

@ -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}.

View File

@ -113,6 +113,7 @@ class MarkwonBuilderImpl implements Markwon.Builder {
textSetter,
parserBuilder.build(),
visitorFactory,
configuration,
Collections.unmodifiableList(plugins)
);
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -77,4 +77,11 @@ public class HeadingSpan extends MetricAffectingSpan implements LeadingMarginSpa
}
}
}
/**
* @since 4.2.0
*/
public int getLevel() {
return level;
}
}

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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
View 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_.

View 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)

View File

@ -0,0 +1,4 @@
POM_NAME=Editor
POM_ARTIFACT_ID=editor
POM_DESCRIPTION=Markdown editor based on Markwon
POM_PACKAGING=aar

View File

@ -0,0 +1 @@
<manifest package="io.noties.markwon.editor" />

View File

@ -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) {
}
}

View File

@ -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();
}

View File

@ -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);
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
});
}
}
}
});
}
}
}

View File

@ -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 +
'}';
}
}
}

View File

@ -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);
}
}
}
}
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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());
}
}
}

View File

@ -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());
}
}
}

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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() {
}
}

View File

@ -0,0 +1,3 @@
# Images (Coil)
https://noties.io/Markwon/docs/v4/image-coil/

View 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)

View 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

View File

@ -0,0 +1 @@
<manifest package="io.noties.markwon.image.coil" />

View File

@ -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);
}
}
}
}
}
}

View File

@ -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);
}
}

View 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)

View 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)

View File

@ -0,0 +1,4 @@
POM_NAME=Inline Parser
POM_ARTIFACT_ID=inline-parser
POM_DESCRIPTION=Markwon customizable commonmark-java InlineParser
POM_PACKAGING=aar

View File

@ -0,0 +1 @@
<manifest package="io.noties.markwon.inlineparser" />

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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 ![alt](#href)}
*
* @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("!");
}
}
}

View File

@ -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("]");
}
}
}

View File

@ -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 &amp;}
*
* @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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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() {
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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)
);
}
}
}

View File

@ -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')

View File

@ -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>

View File

@ -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);
}

View File

@ -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;

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 `&amp;` 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);
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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 (&amp;)
// * 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](#) ![alt](#image) `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);
}
}

View File

@ -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())

View 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>

View File

@ -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>

View File

@ -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',