carta-md
Version:
A lightweight, fully customizable, Markdown editor
291 lines (290 loc) • 11.1 kB
JavaScript
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm, {} from 'remark-gfm';
import remarkRehype, {} from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import { InputEnhancer } from './input';
import { defaultKeyboardShortcuts } from './shortcuts';
import { defaultIcons } from './icons';
import { defaultPrefixes } from './prefixes';
import { Renderer } from './renderer';
import { CustomEvent } from './utils';
import { BROWSER } from 'esm-env';
import { defaultTabOuts } from './tabouts';
const cartaEvents = ['carta-render', 'carta-render-ssr'];
const USE_HIGHLIGHTER = BROWSER ||
// Replaced at build time to tree-shake shiki on the server, if specified
typeof __ENABLE_CARTA_SSR_HIGHLIGHTER__ === 'undefined' ||
__ENABLE_CARTA_SSR_HIGHLIGHTER__ === true;
export class Carta {
sanitizer;
historyOptions;
theme;
shikiOptions;
rehypeOptions;
rendererDebounce;
keyboardShortcuts;
icons;
prefixes;
tabOuts;
grammarRules;
highlightingRules;
textareaListeners;
cartaListeners;
components;
dispatcher = new EventTarget();
gfmOptions;
syncProcessor;
asyncProcessor;
disableTabCapture;
mElement;
mInput;
mRenderer;
mHighlighter;
mSyncTransformers = [];
mAsyncTransformers = [];
get element() {
return this.mElement;
}
get input() {
return this.mInput;
}
get renderer() {
return this.mRenderer;
}
async highlighter() {
if (this.mHighlighter)
return this.mHighlighter;
if (!USE_HIGHLIGHTER)
return;
this.mHighlighter = (async () => {
const hl = await import('./highlight');
const { loadHighlighter, loadDefaultTheme } = hl;
return loadHighlighter({
theme: this.theme ?? (await loadDefaultTheme()),
grammarRules: this.grammarRules,
highlightingRules: this.highlightingRules,
shiki: this.shikiOptions
});
})();
return this.mHighlighter;
}
elementsToBind = [];
constructor(options) {
this.sanitizer = options?.sanitizer || undefined;
this.historyOptions = options?.historyOptions;
this.theme = options?.theme;
this.shikiOptions = options?.shikiOptions;
this.rendererDebounce = options?.rendererDebounce ?? 300;
// Load plugins
this.keyboardShortcuts = [];
this.icons = [];
this.prefixes = [];
this.tabOuts = [];
this.textareaListeners = [];
this.cartaListeners = [];
this.components = [];
this.grammarRules = [];
this.highlightingRules = [];
this.rehypeOptions = options?.rehypeOptions ?? {};
const listeners = [];
for (const ext of options?.extensions ?? []) {
this.keyboardShortcuts.push(...(ext.shortcuts ?? []));
this.icons.push(...(ext.icons ?? []));
this.prefixes.push(...(ext.prefixes ?? []));
this.tabOuts.push(...(ext.tabOuts ?? []));
this.components.push(...(ext.components ?? []));
this.grammarRules.push(...(ext.grammarRules ?? []));
this.highlightingRules.push(...(ext.highlightingRules ?? []));
listeners.push(...(ext.listeners ?? []));
}
// Split different listeners
this.textareaListeners = listeners.filter((it) => !cartaEvents.includes(it[0]));
this.cartaListeners = listeners.filter((it) => cartaEvents.includes(it[0]));
// Setup carta listeners
this.cartaListeners.forEach((it) => {
this.dispatcher.addEventListener(...it);
});
// Load default keyboard shortcuts
this.keyboardShortcuts.push(...defaultKeyboardShortcuts.filter((shortcut) => options?.disableShortcuts === true
? false
: !options?.disableShortcuts?.includes(shortcut.id)));
// Load default icons
this.icons.unshift(...defaultIcons.filter((icon) => options?.disableIcons === true ? false : !options?.disableIcons?.includes(icon.id)));
// Load default prefixes
this.prefixes.push(...defaultPrefixes.filter((prefix) => options?.disablePrefixes === true ? false : !options?.disablePrefixes?.includes(prefix.id)));
// Load default tab-outs
this.tabOuts.push(...defaultTabOuts.filter((tabOut) => options?.disableTabOuts === true ? false : !options?.disableTabOuts?.includes(tabOut.id)));
// Set disableTabCapture
this.disableTabCapture = options?.disableTabCapture ?? false;
// Load unified extensions
this.mSyncTransformers = [];
this.mAsyncTransformers = [];
for (const ext of options?.extensions ?? []) {
for (const transformer of ext.transformers ?? []) {
if (transformer.execution === 'sync') {
this.mSyncTransformers.push(transformer);
}
else {
this.mAsyncTransformers.push(transformer);
}
}
}
this.gfmOptions = options?.gfmOptions;
this.syncProcessor = this.setupSynchronousProcessor({
gfmOptions: this.gfmOptions,
rehypeOptions: this.rehypeOptions
});
this.asyncProcessor = this.setupAsynchronousProcessor({
gfmOptions: this.gfmOptions,
rehypeOptions: this.rehypeOptions
});
for (const ext of options?.extensions ?? []) {
if (ext.onLoad) {
ext.onLoad({
carta: this
});
}
}
}
setupSynchronousProcessor({ gfmOptions, rehypeOptions }) {
const syncProcessor = unified();
const remarkPlugins = this.mSyncTransformers.filter((it) => it.type === 'remark');
const rehypePlugins = this.mSyncTransformers.filter((it) => it.type === 'rehype');
syncProcessor.use(remarkParse);
syncProcessor.use(remarkGfm, gfmOptions);
for (const plugin of remarkPlugins) {
plugin.transform({ processor: syncProcessor, carta: this });
}
syncProcessor.use(remarkRehype, rehypeOptions);
for (const plugin of rehypePlugins) {
plugin.transform({ processor: syncProcessor, carta: this });
}
syncProcessor.use(rehypeStringify);
return syncProcessor;
}
async setupAsynchronousProcessor({ gfmOptions, rehypeOptions }) {
const asyncProcessor = unified();
const remarkPlugins = [...this.mSyncTransformers, ...this.mAsyncTransformers].filter((it) => it.type === 'remark');
const rehypePlugins = [...this.mSyncTransformers, ...this.mAsyncTransformers].filter((it) => it.type === 'rehype');
asyncProcessor.use(remarkParse);
asyncProcessor.use(remarkGfm, gfmOptions);
for (const plugin of remarkPlugins) {
await plugin.transform({ processor: asyncProcessor, carta: this });
}
asyncProcessor.use(remarkRehype, rehypeOptions);
for (const plugin of rehypePlugins) {
await plugin.transform({ processor: asyncProcessor, carta: this });
}
asyncProcessor.use(rehypeStringify);
return asyncProcessor;
}
/**
* Render markdown to html asynchronously.
* @param markdown Markdown input.
* @returns Rendered html.
*/
async render(markdown) {
if (USE_HIGHLIGHTER) {
const hl = await import('./highlight');
const { loadNestedLanguages } = hl;
const highlighter = (await this.highlighter());
await loadNestedLanguages(highlighter, markdown);
}
const processor = await this.asyncProcessor;
const result = await processor.process(markdown);
if (!result)
return '';
const dirty = String(result);
this.dispatcher.dispatchEvent(new CustomEvent('carta-render', { detail: { carta: this } }));
return (this.sanitizer && this.sanitizer(dirty)) ?? dirty;
}
/**
* Render markdown, excluding syntax highlighting (SSR).
* @param markdown Markdown input.
* @returns Rendered html.
*/
renderSSR(markdown) {
const dirty = String(this.syncProcessor.processSync(markdown));
if (typeof dirty != 'string')
return '';
this.dispatcher.dispatchEvent(new CustomEvent('carta-render-ssr', { detail: { carta: this } }));
if (this.sanitizer)
return this.sanitizer(dirty);
return dirty;
}
/**
* **Internal**: set the editor element.
* @param element The editor element.
*/
$setElement(element) {
this.mElement = element;
}
/**
* **Internal**: set the input element.
* @param textarea The input textarea element.
* @param callback Update callback.
*/
$setInput(textarea, container, callback) {
// Remove old listeners if any
const previousInput = this.input;
this.mInput = new InputEnhancer(textarea, container, {
shortcuts: this.keyboardShortcuts,
prefixes: this.prefixes,
tabOuts: this.tabOuts,
listeners: this.textareaListeners,
historyOpts: this.historyOptions,
disableTabCapture: this.disableTabCapture
});
if (previousInput) {
previousInput.events.removeEventListener('update', callback);
this.mInput.history = previousInput.history;
}
this.mInput.events.addEventListener('update', callback);
// Bind elements
this.elementsToBind.forEach((it) => {
it.callback = this.input?.$bindToCaret(it.elem, {
portal: it.portal,
editorElement: this.element
}).destroy;
});
}
/**
* **Internal**: set the renderer element.
* @param container Div container of the rendered element.
*/
$setRenderer(container) {
this.mRenderer = new Renderer(container);
}
/**
* Bind an element to the caret position.
* @param element The element to bind.
* @param portal The portal element.
* @returns The unbind function.
*
* @example
* ```svelte
* <script>
* export let carta;
* </script>
*
* <div use:carta.bindToCaret>
* <!-- Stuff here -->
* </div>
*
* ```
*/
bindToCaret(element, portal = document.querySelector('body')) {
let callback;
if (this.input)
callback = this.input.$bindToCaret(element, { portal, editorElement: this.element }).destroy;
// Bind the element later, when the input is ready
this.elementsToBind.push({ elem: element, portal, callback });
return {
destroy: () => {
callback && callback();
this.elementsToBind = this.elementsToBind.filter((it) => it.elem != element);
}
};
}
}