Update jlatex plugin to be independent of images
This commit is contained in:
parent
64af306e53
commit
e35d3ad044
@ -1,2 +1,6 @@
|
||||
* `Markwon.builder` won't require CorePlugin registration (it is done automatically)
|
||||
to create a builder without CorePlugin - use `Markwon#builderNoCore`
|
||||
* `JLatex` plugin now is not dependent on ImagesPlugin
|
||||
also accepts a ExecutorService (optional, by default cachedThreadPool is used)
|
||||
* AsyncDrawableScheduler now can be called by multiple plugins without penalty
|
||||
internally caches latest state and skips scheduling if drawables are already processed
|
@ -5,23 +5,37 @@ import android.graphics.drawable.Drawable;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.DynamicDrawableSpan;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import ru.noties.markwon.renderer.R;
|
||||
|
||||
public abstract class AsyncDrawableScheduler {
|
||||
|
||||
public static void schedule(@NonNull final TextView textView) {
|
||||
|
||||
final List<AsyncDrawable> list = extract(textView);
|
||||
if (list.size() > 0) {
|
||||
// we need a simple check if current text has already scheduled drawables
|
||||
// we need this in order to allow multiple calls to schedule (different plugins
|
||||
// might use AsyncDrawable), but we do not want to repeat the task
|
||||
//
|
||||
// hm... we need the same thing for unschedule then... we can check if last hash is !null,
|
||||
// if it's not -> unschedule, else ignore
|
||||
|
||||
final Integer lastTextHashCode =
|
||||
(Integer) textView.getTag(R.id.markwon_drawables_scheduler_last_text_hashcode);
|
||||
final int textHashCode = textView.getText().hashCode();
|
||||
if (lastTextHashCode != null
|
||||
&& lastTextHashCode == textHashCode) {
|
||||
return;
|
||||
}
|
||||
textView.setTag(R.id.markwon_drawables_scheduler_last_text_hashcode, textHashCode);
|
||||
|
||||
|
||||
final AsyncDrawableSpan[] spans = extractSpans(textView);
|
||||
if (spans != null
|
||||
&& spans.length > 0) {
|
||||
|
||||
if (textView.getTag(R.id.markwon_drawables_scheduler) == null) {
|
||||
final View.OnAttachStateChangeListener listener = new View.OnAttachStateChangeListener() {
|
||||
@ -41,7 +55,10 @@ public abstract class AsyncDrawableScheduler {
|
||||
textView.setTag(R.id.markwon_drawables_scheduler, listener);
|
||||
}
|
||||
|
||||
for (AsyncDrawable drawable : list) {
|
||||
AsyncDrawable drawable;
|
||||
|
||||
for (AsyncDrawableSpan span : spans) {
|
||||
drawable = span.getDrawable();
|
||||
drawable.setCallback2(new DrawableCallbackImpl(textView, drawable.getBounds()));
|
||||
}
|
||||
}
|
||||
@ -49,57 +66,39 @@ public abstract class AsyncDrawableScheduler {
|
||||
|
||||
// must be called when text manually changed in TextView
|
||||
public static void unschedule(@NonNull TextView view) {
|
||||
for (AsyncDrawable drawable : extract(view)) {
|
||||
drawable.setCallback2(null);
|
||||
|
||||
if (view.getTag(R.id.markwon_drawables_scheduler_last_text_hashcode) == null) {
|
||||
return;
|
||||
}
|
||||
view.setTag(R.id.markwon_drawables_scheduler_last_text_hashcode, null);
|
||||
|
||||
|
||||
final AsyncDrawableSpan[] spans = extractSpans(view);
|
||||
if (spans != null
|
||||
&& spans.length > 0) {
|
||||
for (AsyncDrawableSpan span : spans) {
|
||||
span.getDrawable().setCallback2(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<AsyncDrawable> extract(@NonNull TextView view) {
|
||||
@Nullable
|
||||
private static AsyncDrawableSpan[] extractSpans(@NonNull TextView textView) {
|
||||
|
||||
final List<AsyncDrawable> list;
|
||||
|
||||
final CharSequence cs = view.getText();
|
||||
final CharSequence cs = textView.getText();
|
||||
final int length = cs != null
|
||||
? cs.length()
|
||||
: 0;
|
||||
|
||||
if (length == 0 || !(cs instanceof Spanned)) {
|
||||
//noinspection unchecked
|
||||
list = Collections.EMPTY_LIST;
|
||||
} else {
|
||||
|
||||
final List<AsyncDrawable> drawables = new ArrayList<>(2);
|
||||
|
||||
final Spanned spanned = (Spanned) cs;
|
||||
final AsyncDrawableSpan[] asyncDrawableSpans = spanned.getSpans(0, length, AsyncDrawableSpan.class);
|
||||
if (asyncDrawableSpans != null
|
||||
&& asyncDrawableSpans.length > 0) {
|
||||
for (AsyncDrawableSpan span : asyncDrawableSpans) {
|
||||
drawables.add(span.getDrawable());
|
||||
}
|
||||
if (length == 0
|
||||
|| !(cs instanceof Spanned)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final DynamicDrawableSpan[] dynamicDrawableSpans = spanned.getSpans(0, length, DynamicDrawableSpan.class);
|
||||
if (dynamicDrawableSpans != null
|
||||
&& dynamicDrawableSpans.length > 0) {
|
||||
for (DynamicDrawableSpan span : dynamicDrawableSpans) {
|
||||
final Drawable d = span.getDrawable();
|
||||
if (d != null
|
||||
&& d instanceof AsyncDrawable) {
|
||||
drawables.add((AsyncDrawable) d);
|
||||
}
|
||||
}
|
||||
}
|
||||
// we also could've tried the `nextSpanTransition`, but strangely it leads to worse performance
|
||||
// then direct getSpans
|
||||
|
||||
if (drawables.size() == 0) {
|
||||
//noinspection unchecked
|
||||
list = Collections.EMPTY_LIST;
|
||||
} else {
|
||||
list = drawables;
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
return ((Spanned) cs).getSpans(0, length, AsyncDrawableSpan.class);
|
||||
}
|
||||
|
||||
private AsyncDrawableScheduler() {
|
||||
|
@ -2,5 +2,6 @@
|
||||
<resources>
|
||||
|
||||
<item name="markwon_drawables_scheduler" type="id" />
|
||||
<item name="markwon_drawables_scheduler_last_text_hashcode" type="id" />
|
||||
|
||||
</resources>
|
@ -16,6 +16,7 @@ android {
|
||||
dependencies {
|
||||
|
||||
api project(':markwon-core')
|
||||
api project(':markwon-image')
|
||||
api deps['jlatexmath-android']
|
||||
}
|
||||
|
||||
|
@ -1,24 +1,31 @@
|
||||
package ru.noties.markwon.ext.latex;
|
||||
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.annotation.Px;
|
||||
import android.text.Spanned;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.commonmark.node.Image;
|
||||
import org.commonmark.parser.Parser;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.Scanner;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import ru.noties.jlatexmath.JLatexMathDrawable;
|
||||
import ru.noties.markwon.AbstractMarkwonPlugin;
|
||||
import ru.noties.markwon.MarkwonConfiguration;
|
||||
import ru.noties.markwon.MarkwonVisitor;
|
||||
import ru.noties.markwon.RenderProps;
|
||||
import ru.noties.markwon.image.AsyncDrawable;
|
||||
import ru.noties.markwon.image.AsyncDrawableLoader;
|
||||
import ru.noties.markwon.image.AsyncDrawableScheduler;
|
||||
import ru.noties.markwon.image.AsyncDrawableSpan;
|
||||
import ru.noties.markwon.image.ImageSize;
|
||||
|
||||
/**
|
||||
@ -65,27 +72,29 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
|
||||
|
||||
private final int padding;
|
||||
|
||||
// @since 4.0.0-SNAPSHOT
|
||||
private final ExecutorService executorService;
|
||||
|
||||
Config(@NonNull Builder builder) {
|
||||
this.textSize = builder.textSize;
|
||||
this.background = builder.background;
|
||||
this.align = builder.align;
|
||||
this.fitCanvas = builder.fitCanvas;
|
||||
this.padding = builder.padding;
|
||||
|
||||
// @since 4.0.0-SNAPSHOT
|
||||
ExecutorService executorService = builder.executorService;
|
||||
if (executorService == null) {
|
||||
executorService = Executors.newCachedThreadPool();
|
||||
}
|
||||
this.executorService = executorService;
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String makeDestination(@NonNull String latex) {
|
||||
return SCHEME + "://" + latex;
|
||||
}
|
||||
|
||||
private static final String SCHEME = "jlatexmath";
|
||||
private static final String CONTENT_TYPE = "text/jlatexmath";
|
||||
|
||||
private final Config config;
|
||||
private final JLatextAsyncDrawableLoader jLatextAsyncDrawableLoader;
|
||||
|
||||
JLatexMathPlugin(@NonNull Config config) {
|
||||
this.config = config;
|
||||
this.jLatextAsyncDrawableLoader = new JLatextAsyncDrawableLoader(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -102,70 +111,36 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
|
||||
final String latex = jLatexMathBlock.latex();
|
||||
|
||||
final int length = visitor.length();
|
||||
|
||||
visitor.builder().append(latex);
|
||||
|
||||
final RenderProps renderProps = visitor.renderProps();
|
||||
final MarkwonConfiguration configuration = visitor.configuration();
|
||||
|
||||
ImageProps.DESTINATION.set(renderProps, makeDestination(latex));
|
||||
ImageProps.REPLACEMENT_TEXT_IS_LINK.set(renderProps, false);
|
||||
ImageProps.IMAGE_SIZE.set(renderProps, new ImageSize(new ImageSize.Dimension(100, "%"), null));
|
||||
final AsyncDrawableSpan span = new AsyncDrawableSpan(
|
||||
configuration.theme(),
|
||||
new AsyncDrawable(
|
||||
latex,
|
||||
jLatextAsyncDrawableLoader,
|
||||
configuration.imageSizeResolver(),
|
||||
new ImageSize(
|
||||
new ImageSize.Dimension(100, "%"),
|
||||
null)),
|
||||
AsyncDrawableSpan.ALIGN_BOTTOM,
|
||||
false);
|
||||
|
||||
visitor.setSpansForNode(Image.class, length);
|
||||
visitor.setSpans(length, span);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) {
|
||||
builder
|
||||
.addSchemeHandler(SCHEME, new SchemeHandler() {
|
||||
@Nullable
|
||||
public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) {
|
||||
AsyncDrawableScheduler.unschedule(textView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageItem handle(@NonNull String raw, @NonNull Uri uri) {
|
||||
|
||||
ImageItem item = null;
|
||||
|
||||
try {
|
||||
final byte[] bytes = raw.substring(SCHEME.length()).getBytes("UTF-8");
|
||||
item = new ImageItem(
|
||||
CONTENT_TYPE,
|
||||
new ByteArrayInputStream(bytes));
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
})
|
||||
.addMediaDecoder(CONTENT_TYPE, new MediaDecoder() {
|
||||
@Nullable
|
||||
@Override
|
||||
public Drawable decode(@NonNull InputStream inputStream) {
|
||||
|
||||
final Scanner scanner = new Scanner(inputStream, "UTF-8").useDelimiter("\\A");
|
||||
final String latex = scanner.hasNext()
|
||||
? scanner.next()
|
||||
: null;
|
||||
|
||||
if (latex == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JLatexMathDrawable.builder(latex)
|
||||
.textSize(config.textSize)
|
||||
.background(config.background)
|
||||
.align(config.align)
|
||||
.fitCanvas(config.fitCanvas)
|
||||
.padding(config.padding)
|
||||
.build();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Priority priority() {
|
||||
return Priority.after(ImagesPlugin.class);
|
||||
public void afterSetText(@NonNull TextView textView) {
|
||||
AsyncDrawableScheduler.schedule(textView);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
@ -181,6 +156,9 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
|
||||
|
||||
private int padding;
|
||||
|
||||
// @since 4.0.0-SNAPSHOT
|
||||
private ExecutorService executorService;
|
||||
|
||||
Builder(float textSize) {
|
||||
this.textSize = textSize;
|
||||
}
|
||||
@ -209,9 +187,95 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 4.0.0-SNAPSHOT
|
||||
*/
|
||||
@NonNull
|
||||
public Builder executorService(@NonNull ExecutorService executorService) {
|
||||
this.executorService = executorService;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Config build() {
|
||||
return new Config(this);
|
||||
}
|
||||
}
|
||||
|
||||
// @since 4.0.0-SNAPSHOT
|
||||
private static class JLatextAsyncDrawableLoader extends AsyncDrawableLoader {
|
||||
|
||||
private final Config config;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Map<AsyncDrawable, Future<?>> cache = new HashMap<>(3);
|
||||
|
||||
JLatextAsyncDrawableLoader(@NonNull Config config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void load(@NonNull final AsyncDrawable drawable) {
|
||||
|
||||
// this method must be called from main-thread only (thus synchronization can be skipped)
|
||||
|
||||
// check for currently running tasks associated with provided drawable
|
||||
final Future<?> future = cache.get(drawable);
|
||||
|
||||
// if it's present -> proceed with new execution
|
||||
// as asyncDrawable is immutable, it won't have destination changed (so there is no need
|
||||
// to cancel any started tasks)
|
||||
if (future == null) {
|
||||
|
||||
cache.put(drawable, config.executorService.submit(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
|
||||
// create JLatexMathDrawable
|
||||
final JLatexMathDrawable jLatexMathDrawable =
|
||||
JLatexMathDrawable.builder(drawable.getDestination())
|
||||
.textSize(config.textSize)
|
||||
.background(config.background)
|
||||
.align(config.align)
|
||||
.fitCanvas(config.fitCanvas)
|
||||
.padding(config.padding)
|
||||
.build();
|
||||
|
||||
// we must post to handler, but also have a way to identify the drawable
|
||||
// for which we are posting (in case of cancellation)
|
||||
handler.postAtTime(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// remove entry from cache (it will be present if task is not cancelled)
|
||||
if (cache.remove(drawable) != null
|
||||
&& drawable.isAttached()) {
|
||||
drawable.setResult(jLatexMathDrawable);
|
||||
}
|
||||
|
||||
}
|
||||
}, drawable, SystemClock.uptimeMillis());
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(@NonNull AsyncDrawable drawable) {
|
||||
|
||||
// this method also must be called from main thread only
|
||||
|
||||
final Future<?> future = cache.remove(drawable);
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
}
|
||||
|
||||
// remove all callbacks (via runnable) and messages posted for this drawable
|
||||
handler.removeCallbacksAndMessages(drawable);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Drawable placeholder(@NonNull AsyncDrawable drawable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -50,6 +50,7 @@ dependencies {
|
||||
implementation it['prism4j']
|
||||
implementation it['debug']
|
||||
implementation it['adapt']
|
||||
implementation it['android-svg']
|
||||
}
|
||||
|
||||
deps['annotationProcessor'].with {
|
||||
|
@ -19,9 +19,8 @@ import ru.noties.markwon.MarkwonConfiguration;
|
||||
import ru.noties.markwon.MarkwonPlugin;
|
||||
import ru.noties.markwon.MarkwonSpansFactory;
|
||||
import ru.noties.markwon.MarkwonVisitor;
|
||||
import ru.noties.markwon.movement.MovementMethodPlugin;
|
||||
import ru.noties.markwon.core.MarkwonTheme;
|
||||
import ru.noties.markwon.image.AsyncDrawableLoader;
|
||||
import ru.noties.markwon.movement.MovementMethodPlugin;
|
||||
|
||||
public class BasicPluginsActivity extends Activity {
|
||||
|
||||
@ -168,25 +167,25 @@ public class BasicPluginsActivity extends Activity {
|
||||
final String markdown = "";
|
||||
|
||||
final Markwon markwon = Markwon.builder(this)
|
||||
.usePlugin(ImagesPlugin.create(this))
|
||||
.usePlugin(new AbstractMarkwonPlugin() {
|
||||
@Override
|
||||
public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) {
|
||||
// we can have a custom SchemeHandler
|
||||
// here we will just use networkSchemeHandler to redirect call
|
||||
builder.addSchemeHandler("myownscheme", new SchemeHandler() {
|
||||
|
||||
final NetworkSchemeHandler networkSchemeHandler = NetworkSchemeHandler.create();
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public ImageItem handle(@NonNull String raw, @NonNull Uri uri) {
|
||||
raw = raw.replace("myownscheme", "https");
|
||||
return networkSchemeHandler.handle(raw, Uri.parse(raw));
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
// .usePlugin(ImagesPlugin.create(this))
|
||||
// .usePlugin(new AbstractMarkwonPlugin() {
|
||||
// @Override
|
||||
// public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) {
|
||||
// // we can have a custom SchemeHandler
|
||||
// // here we will just use networkSchemeHandler to redirect call
|
||||
// builder.addSchemeHandler("myownscheme", new SchemeHandler() {
|
||||
//
|
||||
// final NetworkSchemeHandler networkSchemeHandler = NetworkSchemeHandler.create();
|
||||
//
|
||||
// @Nullable
|
||||
// @Override
|
||||
// public ImageItem handle(@NonNull String raw, @NonNull Uri uri) {
|
||||
// raw = raw.replace("myownscheme", "https");
|
||||
// return networkSchemeHandler.handle(raw, Uri.parse(raw));
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// })
|
||||
.build();
|
||||
|
||||
markwon.setMarkdown(textView, markdown);
|
||||
|
@ -26,7 +26,7 @@ public class CustomExtensionActivity extends Activity {
|
||||
// `usePlugin` call
|
||||
final Markwon markwon = Markwon.builder(this)
|
||||
// try commenting out this line to see runtime dependency resolution
|
||||
.usePlugin(ImagesPlugin.create(this))
|
||||
// .usePlugin(ImagesPlugin.create(this))
|
||||
.usePlugin(IconPlugin.create(IconSpanProvider.create(this, 0)))
|
||||
.build();
|
||||
|
||||
|
@ -21,12 +21,12 @@ public class IconPlugin extends AbstractMarkwonPlugin {
|
||||
this.iconSpanProvider = iconSpanProvider;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Priority priority() {
|
||||
// define images dependency
|
||||
return Priority.after(ImagesPlugin.class);
|
||||
}
|
||||
// @NonNull
|
||||
// @Override
|
||||
// public Priority priority() {
|
||||
// // define images dependency
|
||||
// return Priority.after(ImagesPlugin.class);
|
||||
// }
|
||||
|
||||
@Override
|
||||
public void configureParser(@NonNull Parser.Builder builder) {
|
||||
|
@ -42,7 +42,7 @@ public class LatexActivity extends Activity {
|
||||
+ latex + "$$\n\n something like **this**";
|
||||
|
||||
final Markwon markwon = Markwon.builder(this)
|
||||
.usePlugin(ImagesPlugin.create(this))
|
||||
// .usePlugin(ImagesPlugin.create(this))
|
||||
.usePlugin(JLatexMathPlugin.create(textView.getTextSize()))
|
||||
.build();
|
||||
|
||||
|
@ -26,7 +26,11 @@ import ru.noties.markwon.MarkwonConfiguration;
|
||||
import ru.noties.markwon.MarkwonVisitor;
|
||||
import ru.noties.markwon.core.CorePlugin;
|
||||
import ru.noties.markwon.html.HtmlPlugin;
|
||||
import ru.noties.markwon.image.svg.SvgPlugin;
|
||||
import ru.noties.markwon.image.DefaultImageMediaDecoder;
|
||||
import ru.noties.markwon.image.ImagesPlugin;
|
||||
import ru.noties.markwon.image.file.FileSchemeHandler;
|
||||
import ru.noties.markwon.image.network.OkHttpNetworkSchemeHandler;
|
||||
import ru.noties.markwon.image.svg.SvgMediaDecoder;
|
||||
import ru.noties.markwon.recycler.MarkwonAdapter;
|
||||
import ru.noties.markwon.recycler.SimpleEntry;
|
||||
import ru.noties.markwon.recycler.table.TableEntry;
|
||||
@ -73,8 +77,13 @@ public class RecyclerActivity extends Activity {
|
||||
private static Markwon markwon(@NonNull Context context) {
|
||||
return Markwon.builder(context)
|
||||
.usePlugin(CorePlugin.create())
|
||||
.usePlugin(ImagesPlugin.createWithAssets(context))
|
||||
.usePlugin(SvgPlugin.create(context.getResources()))
|
||||
.usePlugin(ImagesPlugin.create(plugin -> {
|
||||
plugin
|
||||
.addSchemeHandler(FileSchemeHandler.createWithAssets(context.getAssets()))
|
||||
.addSchemeHandler(OkHttpNetworkSchemeHandler.create())
|
||||
.addMediaDecoder(SvgMediaDecoder.create())
|
||||
.defaultMediaDecoder(DefaultImageMediaDecoder.create());
|
||||
}))
|
||||
// important to use TableEntryPlugin instead of TablePlugin
|
||||
.usePlugin(TableEntryPlugin.create(context))
|
||||
.usePlugin(HtmlPlugin.create())
|
||||
|
Loading…
x
Reference in New Issue
Block a user