• File: themestrap.plugin.markdowneditor.js
  • Full Path: /home/daemondevin/public_html/portableapps/assets/admin/themestrap.plugin.markdowneditor.js
  • Date Modified: 10/17/2026 7:00 PM
  • File size: 43.84 KB
  • MIME-type: text/plain
  • Charset: utf-8
/**
 * Themestrap MarkdownEditor
 *
 * A distraction-free Markdown writing surface that enhances a plain <textarea>.
 * The textarea stays the source of truth (so normal form posts keep working), and
 * gains a PluginToolbar, smart list/quote continuation, image paste and drop upload,
 * a live preview rendered by PluginMarkdown, and a focus-friendly fullscreen mode.
 *
 * Markup:
 *
 *   <textarea name="body" data-plugin-markdowneditor
 *             data-plugin-options='{"view": "split", "minHeight": 420}'>
 *   # Hello
 *   </textarea>
 *
 * Public API (via stored instance):
 *   const ed = $('#body').data('__pluginMarkdownEditor');
 *   ed.getValue();                 // Markdown string
 *   ed.setValue(md, silent);       // replace content, silent skips the change event
 *   ed.getHtml();                  // rendered preview HTML
 *   ed.insert(text);               // insert at the caret (replaces the selection)
 *   ed.wrap(before, after, hint);  // toggle an inline wrapper around the selection
 *   ed.command('bold');            // run any toolbar command by key
 *   ed.setView('write|split|preview');
 *   ed.fullscreen(true|false);
 *   ed.setStatus('Saved');         // short message in the status bar
 *   ed.focus(); ed.enable(); ed.disable(); ed.destroy();
 *
 * Events (native CustomEvent dispatched on the textarea, bubbles):
 *   ts.markdowneditor.change   detail: { instance, value }
 *   ts.markdowneditor.save     detail: { instance }      Ctrl/Cmd+S pressed
 *   ts.markdowneditor.view     detail: { instance, view }
 *   ts.markdowneditor.upload   detail: { instance, file, url }
 *   ts.markdowneditor.error    detail: { instance, error }
 *   ts.markdowneditor.focus / ts.markdowneditor.blur
 *
 * CSS custom properties (all optional):
 *   --ts-me-bg, --ts-me-border, --ts-me-border-focus, --ts-me-radius, --ts-me-text,
 *   --ts-me-muted, --ts-me-toolbar-bg, --ts-me-font, --ts-me-font-size,
 *   --ts-me-line-height, --ts-me-padding, --ts-me-preview-bg, --ts-me-code-bg, --ts-me-link
 *
 * The renderer option may return HTML or a Promise of HTML, which lets the preview use the
 * same server-side renderer as the published site.
 *
 * Init.js wiring:
 *   if ($.isFunction($.fn['themestrapPluginMarkdownEditor']) && $('[data-plugin-markdowneditor]').length) {
 *       themestrap.fn.intObsInit('[data-plugin-markdowneditor]:not(.manual)', 'themestrapPluginMarkdownEditor');
 *   }
 */
// Markdown Editor
(((themestrap = {}, $) => {
    const instanceName = '__pluginMarkdownEditor';
    const STYLE_ID     = 'ts-markdowneditor-styles';
    const EVENT_NS     = 'markdowneditor';

    let instanceCount = 0;
    let seq           = 0;

    const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
    const MOD    = IS_MAC ? '\u2318' : 'Ctrl+';

    const LIGHT_TOKENS = `
        --me-bg:          var(--ts-me-bg, #ffffff);
        --me-border:      var(--ts-me-border, rgba(0,0,0,.14));
        --me-toolbar-bg:  var(--ts-me-toolbar-bg, rgba(0,0,0,.035));
        --me-text:        var(--ts-me-text, #1f2937);
        --me-muted:       var(--ts-me-muted, rgba(31,41,55,.55));
        --me-preview-bg:  var(--ts-me-preview-bg, rgba(0,0,0,.02));
        --me-code-bg:     var(--ts-me-code-bg, rgba(0,0,0,.06));
        --me-link:        var(--ts-me-link, #0b8a83);
        --me-accent:      var(--ts-me-border-focus, #00a89f);
    `;

    const DARK_TOKENS = `
        --me-bg:          var(--ts-me-bg, rgba(255,255,255,.02));
        --me-border:      var(--ts-me-border, rgba(255,255,255,.10));
        --me-toolbar-bg:  var(--ts-me-toolbar-bg, rgba(0,0,0,.20));
        --me-text:        var(--ts-me-text, #cdd8e6);
        --me-muted:       var(--ts-me-muted, rgba(205,216,230,.55));
        --me-preview-bg:  var(--ts-me-preview-bg, rgba(255,255,255,.03));
        --me-code-bg:     var(--ts-me-code-bg, rgba(255,255,255,.08));
        --me-link:        var(--ts-me-link, #00c7be);
        --me-accent:      var(--ts-me-border-focus, #00c7be);
    `;

    const INJECTED_CSS = `
        .ts-mdeditor { ${LIGHT_TOKENS} }
        @media (prefers-color-scheme: dark) {
            .ts-mdeditor { ${DARK_TOKENS} }
        }
        html.dark .ts-mdeditor,
        [data-bs-theme="dark"] .ts-mdeditor { ${DARK_TOKENS} }
        [data-bs-theme="light"] .ts-mdeditor,
        html [data-bs-theme="light"] .ts-mdeditor { ${LIGHT_TOKENS} }

        .ts-mdeditor {
            display: flex;
            flex-direction: column;
            background: var(--me-bg);
            border: 1px solid var(--me-border);
            border-radius: var(--ts-me-radius, 6px);
            overflow: hidden;
            transition: border-color .15s, box-shadow .15s;
        }
        .ts-mdeditor.ts-me-focused {
            border-color: var(--me-accent);
            box-shadow: 0 0 0 3px color-mix(in srgb, var(--me-accent) 18%, transparent);
        }
        .ts-mdeditor.ts-me-disabled { opacity: .6; }
        .ts-mdeditor.ts-me-disabled .ts-toolbar { pointer-events: none; }

        .ts-mdeditor .ts-toolbar {
            --ts-tb-bg:     var(--me-toolbar-bg);
            --ts-tb-border: transparent;
            --ts-tb-shadow: none;
            --ts-tb-radius: 0;
            width: 100%;
            max-width: 100%;
            flex-wrap: wrap;
            border-radius: 0;
            border-bottom: 1px solid var(--me-border);
        }
        .ts-mdeditor .ts-toolbar [data-toolbar-toggle-item][aria-pressed="true"] {
            --ts-tb-on-bg: var(--me-accent);
        }
        .ts-me-spacer { flex: 1 1 auto; }

        .ts-me-panes { display: flex; flex: 1; min-height: 0; }
        .ts-me-write,
        .ts-me-preview { flex: 1 1 0; min-width: 0; position: relative; }
        .ts-me-view-write   .ts-me-preview { display: none; }
        .ts-me-view-preview .ts-me-write   { display: none; }
        .ts-me-view-split   .ts-me-write   { border-right: 1px solid var(--me-border); }
        .ts-me-view-split .ts-me-panes,
        .ts-me-view-preview .ts-me-panes { height: var(--ts-me-height, 420px); }
        .ts-me-view-split .ts-me-write,
        .ts-me-view-split .ts-me-preview,
        .ts-me-view-preview .ts-me-preview { overflow-y: auto; }

        .ts-me-input {
            display: block;
            width: 100%;
            height: 100%;
            min-height: var(--ts-me-height, 420px);
            margin: 0;
            padding: var(--ts-me-padding, 16px 18px);
            border: 0;
            outline: 0;
            resize: none;
            background: transparent;
            color: var(--me-text);
            font-family: var(--ts-me-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
            font-size: var(--ts-me-font-size, 15px);
            line-height: var(--ts-me-line-height, 1.7);
            tab-size: 4;
            box-sizing: border-box;
        }
        .ts-me-input::placeholder { color: var(--me-muted); opacity: .8; }
        .ts-me-view-write .ts-me-input { overflow: hidden; }
        .ts-me-view-split .ts-me-input { min-height: 100%; }

        .ts-me-preview {
            padding: var(--ts-me-padding, 16px 18px);
            background: var(--me-preview-bg);
            color: var(--me-text);
            font-size: 15px;
            line-height: 1.7;
            word-break: break-word;
        }
        .ts-me-preview-body > :first-child { margin-top: 0; }
        .ts-me-preview-body > :last-child  { margin-bottom: 0; }
        .ts-me-preview a { color: var(--me-link); }
        .ts-me-preview img { max-width: 100%; height: auto; border-radius: 4px; }
        .ts-me-preview blockquote { margin: 0 0 1em; padding-left: 1em; border-left: 3px solid var(--me-accent); color: var(--me-muted); }
        .ts-me-preview pre { background: var(--me-code-bg); padding: 12px 14px; border-radius: 6px; overflow-x: auto; }
        .ts-me-preview code { background: var(--me-code-bg); padding: .1em .35em; border-radius: 4px; font-size: .9em; }
        .ts-me-preview pre code { background: none; padding: 0; }
        .ts-me-preview table { border-collapse: collapse; margin-bottom: 1em; }
        .ts-me-preview th, .ts-me-preview td { border: 1px solid var(--me-border); padding: 4px 10px; }
        .ts-me-preview hr { border: 0; border-top: 1px solid var(--me-border); }
        .ts-me-preview-empty { color: var(--me-muted); font-style: italic; }

        .ts-me-status {
            display: flex;
            gap: 14px;
            align-items: center;
            padding: 6px 14px;
            border-top: 1px solid var(--me-border);
            background: var(--me-toolbar-bg);
            color: var(--me-muted);
            font-size: 12px;
        }
        .ts-me-msg { margin-left: auto; }
        .ts-me-dropping .ts-me-write::after {
            content: 'Drop image to upload';
            position: absolute;
            inset: 8px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: 2px dashed var(--me-accent);
            border-radius: 8px;
            background: color-mix(in srgb, var(--me-bg) 88%, transparent);
            color: var(--me-accent);
            font-weight: 600;
            pointer-events: none;
        }

        .ts-mdeditor.ts-me-fullscreen {
            position: fixed;
            inset: 0;
            z-index: 1040;
            border-radius: 0;
            border: 0;
            --ts-me-height: calc(100vh - 100px);
        }
        .ts-mdeditor.ts-me-fullscreen .ts-me-panes { height: auto; }
        .ts-mdeditor.ts-me-fullscreen.ts-me-view-write .ts-me-write { overflow-y: auto; }
        .ts-mdeditor.ts-me-fullscreen.ts-me-view-write .ts-me-input { overflow: auto; }
        body.ts-me-lock { overflow: hidden; }
    `;

    // Toolbar commands. type: wrap | line | block | action
    const CMDS = {
        bold:      { icon: 'fas fa-bold',          label: 'Bold',          key: 'B' },
        italic:    { icon: 'fas fa-italic',        label: 'Italic',        key: 'I' },
        strike:    { icon: 'fas fa-strikethrough', label: 'Strikethrough' },
        code:      { icon: 'fas fa-code',          label: 'Inline code' },
        h2:        { icon: 'fas fa-heading',       label: 'Heading',       text: 'H2' },
        h3:        { icon: 'fas fa-heading',       label: 'Subheading',    text: 'H3' },
        quote:     { icon: 'fas fa-quote-left',    label: 'Quote' },
        ul:        { icon: 'fas fa-list-ul',       label: 'Bullet list' },
        ol:        { icon: 'fas fa-list-ol',       label: 'Numbered list' },
        task:      { icon: 'fas fa-list-check',    label: 'Task list' },
        link:      { icon: 'fas fa-link',          label: 'Link',          key: 'K' },
        image:     { icon: 'fas fa-image',         label: 'Image' },
        codeblock: { icon: 'fas fa-file-code',     label: 'Code block' },
        hr:        { icon: 'fas fa-minus',         label: 'Divider' },
        table:     { icon: 'fas fa-table',         label: 'Table' }
    };

    const VIEWS = {
        write:   { icon: 'fas fa-pen',            label: 'Write' },
        split:   { icon: 'fas fa-table-columns',  label: 'Write and preview' },
        preview: { icon: 'fas fa-eye',            label: 'Preview' }
    };

    const titleFor = (d) => (d.key ? `${d.label} (${MOD}${d.key})` : d.label);
    const toPx     = (v) => (typeof v === 'number' ? `${v}px` : v);

    class PluginMarkdownEditor {
        constructor($el, opts) {
            return this.initialize($el, opts);
        }

        initialize($el, opts) {
            if ($el.data(instanceName)) {
                return this;
            }

            this.$el     = $el;
            this._uid    = ++seq;
            this._ns     = `${EVENT_NS}${this._uid}`;
            this._view   = 'write';
            this._failed = false;
            this._timer  = null;

            this
                .setData()
                .setOptions(opts)
                .build()
                .events();

            return this;
        }

        setData() {
            this.$el.data(instanceName, this);
            return this;
        }

        setOptions(opts) {
            this.options = $.extend(true, {}, PluginMarkdownEditor.defaults, opts, {
                wrapper: this.$el
            });
            // Arrays are replaced wholesale, not merged index by index
            if (opts && Array.isArray(opts.toolbar)) this.options.toolbar = opts.toolbar.slice();
            if (opts && typeof opts.imageUpload === 'function') this.options.imageUpload = opts.imageUpload;
            if (opts && typeof opts.renderer === 'function')    this.options.renderer = opts.renderer;
            return this;
        }

        build() {
            const self = this;
            const opts = self.options;

            if (!self.$el.is('textarea')) {
                console.error('PluginMarkdownEditor: the host element must be a <textarea>.');
                self._failed = true;
                self.$el.removeData(instanceName);
                return this;
            }
            if (opts.toolbar !== false && typeof $.fn.themestrapPluginToolbar !== 'function') {
                if (themestrap.fn && typeof themestrap.fn.showErrorMessage === 'function') {
                    themestrap.fn.showErrorMessage('Failed to Load File',
                        'Failed to load: PluginToolbar - Include the following file(s): (components/themestrap.plugin.toolbar.js)');
                } else {
                    console.error('PluginMarkdownEditor: PluginToolbar must be loaded first.');
                }
                self._failed = true;
                self.$el.removeData(instanceName);
                return this;
            }

            instanceCount++;
            if (!document.getElementById(STYLE_ID)) {
                const style       = document.createElement('style');
                style.id          = STYLE_ID;
                style.textContent = INJECTED_CSS;
                document.head.appendChild(style);
            }

            self.$ta    = self.$el;
            self.$wrap  = $('<div class="ts-mdeditor"></div>');
            self.$panes = $('<div class="ts-me-panes"></div>');
            self.$write = $('<div class="ts-me-write"></div>');

            // The textarea keeps its place in the DOM tree, inside the new wrapper
            self.$wrap.insertBefore(self.$ta);
            self.$ta.addClass('ts-me-input').attr({ spellcheck: 'true' });
            if (opts.placeholder) self.$ta.attr('placeholder', opts.placeholder);
            if (opts.ariaLabel && !self.$ta.attr('aria-label')) self.$ta.attr('aria-label', opts.ariaLabel);
            self.$write.append(self.$ta);
            self.$panes.append(self.$write);

            if (opts.minHeight) self.$wrap[0].style.setProperty('--ts-me-height', toPx(opts.minHeight));

            if (opts.preview) {
                self.$preview = $('<div class="ts-me-preview" tabindex="-1"><div class="ts-me-preview-body"></div></div>');
                self.$previewBody = self.$preview.find('.ts-me-preview-body');
                self.$panes.append(self.$preview);
                if (!opts.renderer && typeof $.fn.themestrapPluginMarkdown === 'function') {
                    // A detached host: PluginMarkdown renders into it and the HTML is copied across
                    self.$mdHost = $('<div></div>');
                    self.$mdHost.themestrapPluginMarkdown({
                        sanitize: opts.sanitize, breaks: opts.breaks, highlight: false
                    });
                    self._md = $(self.$mdHost[0]).data('__pluginMarkdown');
                }
            }

            if (opts.toolbar !== false) {
                self.$toolbarEl = self._buildToolbarEl();
                self.$wrap.append(self.$toolbarEl);
                self.$toolbarEl.themestrapPluginToolbar($.extend({}, opts.toolbarOptions));
            }

            self.$wrap.append(self.$panes);

            if (opts.statusBar) {
                self.$status = $('<div class="ts-me-status"><span class="ts-me-words"></span><span class="ts-me-time"></span><span class="ts-me-msg" role="status"></span></div>');
                self.$wrap.append(self.$status);
            }

            self.setView(opts.view, true);
            self._updateStatus();
            self._autoGrow();
            if (opts.disabled) self.disable();

            return this;
        }

        _buildToolbarEl() {
            const self = this;
            const t    = self.options.toolbar;
            const keys = (t === true || !Array.isArray(t)) ? PluginMarkdownEditor.defaults.toolbar : t;
            const parts = [];
            let group = [];

            const flush = () => { if (group.length) { parts.push(group.join('')); group = []; } };

            keys.forEach((k) => {
                if (k === '|')      { flush(); return; }
                if (k === 'views')  { flush(); parts.push(self._viewsHtml()); return; }
                if (k === 'spacer') { flush(); parts.push('<span class="ts-me-spacer"></span>'); return; }
                if (k === 'fullscreen') {
                    flush();
                    parts.push(`<button type="button" class="ts-tb-icon" data-toolbar-button data-toolbar-value="fullscreen"
                        title="Fullscreen (Esc to leave)" aria-label="Fullscreen"><i class="fas fa-expand" aria-hidden="true"></i></button>`);
                    return;
                }
                const d = CMDS[k];
                if (!d) return;
                group.push(`<button type="button" class="ts-tb-icon" data-toolbar-button data-toolbar-value="${k}"
                    title="${titleFor(d)}" aria-label="${d.label}">
                    ${d.text ? `<span class="ts-me-ico-text">${d.text}</span>` : `<i class="${d.icon}" aria-hidden="true"></i>`}
                </button>`);
            });
            flush();

            return $(`<div>${parts.join('<hr data-toolbar-separator>')}</div>`);
        }

        _viewsHtml() {
            const items = Object.keys(VIEWS).map((k) => {
                const d = VIEWS[k];
                return `<button type="button" class="ts-tb-icon" data-toolbar-toggle-item data-toolbar-value="${k}"
                    title="${d.label}" aria-label="${d.label}"><i class="${d.icon}" aria-hidden="true"></i></button>`;
            }).join('');
            return `<div data-toolbar-toggle-group data-toolbar-type="single" data-toolbar-group-label="Editor view">${items}</div>`;
        }

        events() {
            const self = this;
            if (self._failed) return this;

            if (self.$toolbarEl) {
                // Keep the caret and selection in the textarea while toolbar buttons are pressed
                self.$toolbarEl.on(`mousedown.${EVENT_NS}`, (e) => e.preventDefault());
                self.$toolbarEl[0].addEventListener('toolbar:button', (e) => {
                    if (e.detail.value === 'fullscreen') self.fullscreen(!self._full);
                    else self.command(e.detail.value);
                });
                self.$toolbarEl[0].addEventListener('toolbar:toggle', (e) => {
                    if (VIEWS[e.detail.value]) self.setView(e.detail.value);
                });
            }

            self.$ta
                .on(`focus.${EVENT_NS}`, () => {
                    self.$wrap.addClass('ts-me-focused');
                    self._dispatch('ts.markdowneditor.focus');
                })
                .on(`blur.${EVENT_NS}`, () => {
                    self.$wrap.removeClass('ts-me-focused');
                    self._dispatch('ts.markdowneditor.blur');
                })
                .on(`input.${EVENT_NS}`, () => {
                    self._autoGrow();
                    self._updateStatus();
                    self._queuePreview();
                    self._dispatch('ts.markdowneditor.change', { value: self.getValue() });
                    if (typeof self.options.onChange === 'function') self.options.onChange.call(self, self.getValue());
                })
                .on(`keydown.${EVENT_NS}`, (e) => self._onKeydown(e))
                .on(`paste.${EVENT_NS}`, (e) => self._onPaste(e))
                .on(`scroll.${EVENT_NS}`, () => self._syncScroll('write'));

            if (self.$preview) {
                self.$preview.on(`scroll.${EVENT_NS}`, () => self._syncScroll('preview'));
            }

            // Drag and drop images onto the writing surface
            self.$write
                .on(`dragover.${EVENT_NS} dragenter.${EVENT_NS}`, (e) => {
                    if (!self._hasImageFiles(e.originalEvent.dataTransfer)) return;
                    e.preventDefault();
                    self.$wrap.addClass('ts-me-dropping');
                })
                .on(`dragleave.${EVENT_NS} drop.${EVENT_NS}`, (e) => {
                    if (e.type === 'dragleave' && self.$write[0].contains(e.originalEvent.relatedTarget)) return;
                    self.$wrap.removeClass('ts-me-dropping');
                })
                .on(`drop.${EVENT_NS}`, (e) => {
                    const dt = e.originalEvent.dataTransfer;
                    if (!self._hasImageFiles(dt)) return;
                    e.preventDefault();
                    self._uploadFiles(dt.files);
                });

            $(document).on(`keydown.${self._ns}`, (e) => {
                if (e.key === 'Escape' && self._full) self.fullscreen(false);
            });

            return this;
        }

        // Selection and text helpers

        _sel() {
            const ta = this.$ta[0];
            return { s: ta.selectionStart, e: ta.selectionEnd, v: ta.value };
        }

        // Replaces [start, end) with text through the browser edit stack when possible, so undo keeps working
        _replace(start, end, text, selStart, selEnd) {
            const ta = this.$ta[0];
            ta.focus();
            ta.setSelectionRange(start, end);
            let ok = false;
            try { ok = document.execCommand('insertText', false, text); } catch (err) { ok = false; }
            if (!ok || ta.value.slice(start, start + text.length) !== text) {
                ta.setRangeText(text, start, end, 'end');
                ta.dispatchEvent(new Event('input', { bubbles: true }));
            }
            const a = selStart === undefined ? start + text.length : selStart;
            const b = selEnd   === undefined ? a : selEnd;
            ta.setSelectionRange(a, b);
            return this;
        }

        _lineRange(s, e) {
            const v = this.$ta[0].value;
            const start = v.lastIndexOf('\n', s - 1) + 1;
            let end = v.indexOf('\n', e);
            if (end === -1) end = v.length;
            // A selection that ends right after a newline should not pull in the next line
            if (e > s && v[e - 1] === '\n') end = e - 1;
            return { start, end };
        }

        // Public editing API

        insert(text) {
            const { s, e } = this._sel();
            return this._replace(s, e, text);
        }

        wrap(before, after, hint) {
            after = after === undefined ? before : after;
            const { s, e, v } = this._sel();
            const sel = v.slice(s, e);
            const bl = before.length, al = after.length;

            // Already wrapped outside the selection: unwrap
            if (s >= bl && v.slice(s - bl, s) === before && v.slice(e, e + al) === after) {
                return this._replace(s - bl, e + al, sel, s - bl, s - bl + sel.length);
            }
            // Selection includes the wrappers: unwrap
            if (sel.length >= bl + al && sel.startsWith(before) && sel.endsWith(after)) {
                const inner = sel.slice(bl, sel.length - al);
                return this._replace(s, e, inner, s, s + inner.length);
            }
            const inner = sel || hint || '';
            return this._replace(s, e, before + inner + after, s + bl, s + bl + inner.length);
        }

        // Applies a transform to every line in the selection
        _lines(fn) {
            const { s, e, v } = this._sel();
            const { start, end } = this._lineRange(s, e);
            const lines = v.slice(start, end).split('\n');
            const out = fn(lines).join('\n');
            return this._replace(start, end, out, start, start + out.length);
        }

        _toggleLinePrefix(re, makePrefix, stripOthers) {
            return this._lines((lines) => {
                const filled = lines.filter((l) => l.trim() !== '');
                const all    = filled.length && filled.every((l) => re.test(l));
                let n = 0;
                return lines.map((l) => {
                    if (l.trim() === '') return l;
                    if (all) return l.replace(re, '');
                    n++;
                    const base = stripOthers ? l.replace(stripOthers, '') : l.replace(re, '');
                    return makePrefix(n) + base;
                });
            });
        }

        command(name) {
            const self = this;
            const { s, e, v } = self._sel();
            switch (name) {
                case 'bold':      return self.wrap('**', '**', 'bold text');
                case 'italic':    return self.wrap('*', '*', 'italic text');
                case 'strike':    return self.wrap('~~', '~~', 'text');
                case 'code':
                    return v.slice(s, e).indexOf('\n') !== -1 ? self.command('codeblock') : self.wrap('`', '`', 'code');
                case 'h2':
                case 'h3': {
                    const pre = name === 'h2' ? '## ' : '### ';
                    return self._lines((lines) => lines.map((l) => {
                        if (l.indexOf(pre) === 0) return l.slice(pre.length);
                        return pre + l.replace(/^#{1,6}\s+/, '');
                    }));
                }
                case 'quote': return self._toggleLinePrefix(/^>\s?/, () => '> ');
                case 'ul':    return self._toggleLinePrefix(/^(\s*)[-*+]\s+(\[[ xX]\]\s+)?/, () => '- ', /^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/);
                case 'ol':    return self._toggleLinePrefix(/^(\s*)\d+[.)]\s+/, (n) => `${n}. `, /^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/);
                case 'task':  return self._toggleLinePrefix(/^(\s*)[-*+]\s+\[[ xX]\]\s+/, () => '- [ ] ', /^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/);
                case 'link': {
                    const sel = v.slice(s, e);
                    if (/^https?:\/\/\S+$/.test(sel)) {
                        return self._replace(s, e, `[link](${sel})`, s + 1, s + 5);
                    }
                    const text = sel || 'link text';
                    const url  = 'https://';
                    const at   = s + text.length + 3;
                    return self._replace(s, e, `[${text}](${url})`, at, at + url.length);
                }
                case 'image': {
                    if (typeof self.options.imageUpload === 'function') return self._pickImage();
                    const alt = v.slice(s, e) || 'alt text';
                    const url = 'https://';
                    const at  = s + alt.length + 4;
                    return self._replace(s, e, `![${alt}](${url})`, at, at + url.length);
                }
                case 'codeblock': {
                    const sel   = v.slice(s, e);
                    const lead  = s > 0 && v[s - 1] !== '\n' ? '\n' : '';
                    const block = `${lead}\`\`\`\n${sel}\n\`\`\`\n`;
                    const a     = s + lead.length + 4;
                    return self._replace(s, e, block, a, a + sel.length);
                }
                case 'hr': {
                    const lead = s > 0 && v[s - 1] !== '\n' ? '\n\n' : (s > 0 ? '\n' : '');
                    return self._replace(s, e, `${lead}---\n\n`);
                }
                case 'table': {
                    const lead = s > 0 && v[s - 1] !== '\n' ? '\n\n' : (s > 0 ? '\n' : '');
                    const tpl  = '| Column | Column |\n| --- | --- |\n| Cell | Cell |\n';
                    return self._replace(s, e, lead + tpl, s + lead.length + 2, s + lead.length + 8);
                }
            }
            return self;
        }

        // Keyboard

        _onKeydown(e) {
            const self = this;
            const mod  = e.ctrlKey || e.metaKey;
            const k    = String(e.key).toLowerCase();

            if (mod && !e.altKey) {
                if (!e.shiftKey && k === 'b') { e.preventDefault(); self.command('bold');   return; }
                if (!e.shiftKey && k === 'i') { e.preventDefault(); self.command('italic'); return; }
                if (!e.shiftKey && k === 'k') { e.preventDefault(); self.command('link');   return; }
                if (!e.shiftKey && k === 's') {
                    e.preventDefault();
                    self._dispatch('ts.markdowneditor.save');
                    return;
                }
            }

            if (e.key === 'Enter' && !mod && !e.shiftKey && !e.altKey && !e.isComposing) {
                if (self._continueBlock()) e.preventDefault();
                return;
            }

            if (e.key === 'Tab' && !mod && !e.altKey) {
                if (self._indent(e.shiftKey)) e.preventDefault();
            }
        }

        // Continues lists and quotes on Enter. Returns true when it handled the key.
        _continueBlock() {
            const { s, e, v } = this._sel();
            if (s !== e) return false;
            const lineStart = v.lastIndexOf('\n', s - 1) + 1;
            const lineEnd   = (v.indexOf('\n', s) === -1) ? v.length : v.indexOf('\n', s);
            const line      = v.slice(lineStart, lineEnd);

            const m = line.match(/^(\s*)(?:([-*+])|(\d+)([.)]))\s+(\[[ xX]\]\s+)?/) || line.match(/^(\s*)(>)\s?/);
            if (!m) return false;
            const marker = m[0];
            // Caret must be after the marker, otherwise this is a plain line break
            if (s - lineStart < marker.length) return false;

            // An empty item ends the list or quote
            if (line.slice(marker.length).trim() === '') {
                this._replace(lineStart, lineEnd, '', lineStart, lineStart);
                return true;
            }

            let next;
            if (m[2] === '>')       next = `${m[1]}> `;
            else if (m[3] !== undefined) next = `${m[1]}${parseInt(m[3], 10) + 1}${m[4]} `;
            else                    next = `${m[1]}${m[2]} `;
            if (m[5]) next += '[ ] ';

            this._replace(s, s, `\n${next}`);
            return true;
        }

        _indent(outdent) {
            const { s, e, v } = this._sel();
            const pad = ' '.repeat(this.options.tabSize);
            const { start, end } = this._lineRange(s, e);
            const block = v.slice(start, end);
            const listy = /^(\s*)([-*+]|\d+[.)])\s/.test(block.split('\n')[0]);
            // Only capture Tab inside lists or multi-line selections, so keyboard focus is never trapped
            if (!listy && block.indexOf('\n') === -1) return false;

            const out = block.split('\n').map((l) => {
                if (!outdent) return l.trim() === '' ? l : pad + l;
                return l.indexOf(pad) === 0 ? l.slice(pad.length) : l.replace(/^ {1,3}/, '');
            }).join('\n');
            const delta = out.length - block.length;
            this._replace(start, end, out, s === start ? start : Math.max(start, s + (out.indexOf('\n') === -1 ? delta : 0)), e + delta);
            return true;
        }

        // Paste: images upload, a URL pasted over a selection becomes a link
        _onPaste(e) {
            const self = this;
            const cd   = (e.originalEvent || e).clipboardData;
            if (!cd) return;

            const files = Array.prototype.filter.call(cd.files || [], (f) => /^image\//.test(f.type));
            if (files.length && typeof self.options.imageUpload === 'function') {
                e.preventDefault();
                self._uploadFiles(files);
                return;
            }

            const text = (cd.getData('text/plain') || '').trim();
            const { s, e: end, v } = self._sel();
            if (s !== end && /^https?:\/\/\S+$/.test(text)) {
                e.preventDefault();
                const label = v.slice(s, end);
                self._replace(s, end, `[${label}](${text})`);
            }
        }

        // Images

        _hasImageFiles(dt) {
            return !!dt && typeof this.options.imageUpload === 'function'
                && Array.prototype.some.call(dt.items || dt.files || [], (i) => /^image\//.test(i.type));
        }

        _pickImage() {
            const self  = this;
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = 'image/*';
            input.multiple = true;
            input.addEventListener('change', () => self._uploadFiles(input.files));
            input.click();
            return self;
        }

        _uploadFiles(fileList) {
            const self = this;
            Array.prototype.filter.call(fileList, (f) => /^image\//.test(f.type)).forEach((file) => {
                const alt  = (file.name || 'image').replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
                const mark = `![Uploading ${alt}\u2026]()`;
                const { s, e, v } = self._sel();
                const lead = s > 0 && v[s - 1] !== '\n' ? '\n\n' : '';
                self._replace(s, e, `${lead}${mark}\n`);
                self.setStatus('Uploading image\u2026');

                Promise.resolve()
                    .then(() => self.options.imageUpload(file))
                    .then((url) => {
                        if (!url) throw new Error('No image address returned');
                        self._swap(mark, `![${alt}](${url})`);
                        self.setStatus('Image added');
                        self._dispatch('ts.markdowneditor.upload', { file, url });
                    })
                    .catch((err) => {
                        self._swap(`${mark}\n`, '');
                        self._swap(mark, '');
                        self.setStatus('Upload failed');
                        self._dispatch('ts.markdowneditor.error', { error: err });
                    });
            });
            return self;
        }

        // Replaces the first occurrence of a marker string without disturbing the caret much
        _swap(find, repl) {
            const ta  = this.$ta[0];
            const i   = ta.value.indexOf(find);
            if (i === -1) return this;
            const hadFocus = document.activeElement === ta;
            const s = ta.selectionStart, e = ta.selectionEnd;
            const delta = repl.length - find.length;
            ta.setSelectionRange(i, i + find.length);
            let ok = false;
            try { ok = document.execCommand('insertText', false, repl); } catch (err) { ok = false; }
            if (!ok) {
                ta.setRangeText(repl, i, i + find.length, 'end');
                ta.dispatchEvent(new Event('input', { bubbles: true }));
            }
            const shift = (p) => (p > i ? p + delta : p);
            ta.setSelectionRange(shift(s), shift(e));
            if (!hadFocus) ta.blur();
            return this;
        }

        // View, preview and layout

        setView(view, silent) {
            if (!VIEWS[view]) return this;
            if (view !== 'write' && !this.$preview) view = 'write';
            this._view = view;
            this.$wrap
                .removeClass('ts-me-view-write ts-me-view-split ts-me-view-preview')
                .addClass(`ts-me-view-${view}`);

            if (this.$toolbarEl) {
                this.$toolbarEl.find('[data-toolbar-toggle-item]').each(function() {
                    const on = this.getAttribute('data-toolbar-value') === view;
                    this.setAttribute('aria-pressed', on ? 'true' : 'false');
                    this.classList.toggle('ts-toolbar__toggle-item--on', on);
                });
            }
            if (view !== 'write') this._renderPreview();
            this._autoGrow();
            if (!silent) this._dispatch('ts.markdowneditor.view', { view });
            return this;
        }

        getView() { return this._view; }

        fullscreen(on) {
            on = !!on;
            if (on === !!this._full) return this;
            this._full = on;
            this.$wrap.toggleClass('ts-me-fullscreen', on);
            $('body').toggleClass('ts-me-lock', on);
            this._autoGrow();
            if (on) this.$ta.trigger('focus');
            return this;
        }

        _autoGrow() {
            const ta = this.$ta[0];
            if (!this.options.autoGrow) return this;
            if (this._view !== 'write' || this._full) { ta.style.height = ''; return this; }
            ta.style.height = 'auto';
            ta.style.height = `${Math.max(ta.scrollHeight, 0)}px`;
            return this;
        }

        _queuePreview() {
            if (this._view === 'write' || !this.$preview) return this;
            clearTimeout(this._timer);
            this._timer = setTimeout(() => this._renderPreview(), this.options.previewDelay);
            return this;
        }

        _renderPreview() {
            if (!this.$preview) return this;
            const md = this.getValue();
            let html = '';
            if (md.trim() === '') {
                this.$previewBody.html('<p class="ts-me-preview-empty">Nothing to preview yet.</p>');
                return this;
            }
            if (typeof this.options.renderer === 'function') {
                const out = this.options.renderer(md);
                if (out && typeof out.then === 'function') {
                    // Asynchronous renderers (a server round trip): only the latest result is applied
                    const token = this._rtoken = (this._rtoken || 0) + 1;
                    out.then((h) => { if (token === this._rtoken) this.$previewBody.html(h); })
                       .catch(() => { if (token === this._rtoken) this.$previewBody.html(this._fallbackHtml(md)); });
                    return this;
                }
                html = out;
            } else if (this._md) {
                this._md.render(md);
                html = this._md.getHtml();
            } else {
                html = this._fallbackHtml(md);
            }
            this.$previewBody.html(html);
            return this;
        }

        _fallbackHtml(md) {
            return $('<div></div>').text(md).html().replace(/\n/g, '<br>');
        }

        _syncScroll(from) {
            if (!this.options.syncScroll || this._view !== 'split' || this._syncing) return this;
            const a = from === 'write' ? this.$write[0] : this.$preview[0];
            const b = from === 'write' ? this.$preview[0] : this.$write[0];
            // In split view the write pane scrolls (not the textarea itself)
            const src = from === 'write' ? this.$write[0] : a;
            const max = src.scrollHeight - src.clientHeight;
            if (max <= 0) return this;
            this._syncing = true;
            b.scrollTop = (src.scrollTop / max) * (b.scrollHeight - b.clientHeight);
            requestAnimationFrame(() => { this._syncing = false; });
            return this;
        }

        _updateStatus() {
            if (!this.$status) return this;
            const text  = this.getValue().replace(/```[\s\S]*?```/g, ' ').replace(/[#>*_`~\[\]()!|-]+/g, ' ');
            const words = (text.match(/[\p{L}\p{N}']+/gu) || []).length;
            this.$status.find('.ts-me-words').text(`${words} word${words === 1 ? '' : 's'}`);
            this.$status.find('.ts-me-time').text(`${Math.max(words ? 1 : 0, Math.ceil(words / 220))} min read`);
            return this;
        }

        setStatus(msg) {
            if (this.$status) this.$status.find('.ts-me-msg').text(msg || '');
            return this;
        }

        _dispatch(name, extra) {
            this.$ta[0].dispatchEvent(new CustomEvent(name, {
                detail: $.extend({ instance: this }, extra),
                bubbles: true
            }));
            return this;
        }

        // Public API

        getValue() { return this.$ta.val(); }

        setValue(md, silent) {
            this.$ta.val(md || '');
            this._autoGrow();
            this._updateStatus();
            if (this._view !== 'write') this._renderPreview();
            if (!silent) this._dispatch('ts.markdowneditor.change', { value: this.getValue() });
            return this;
        }

        getHtml() {
            if (typeof this.options.renderer === 'function') return this.options.renderer(this.getValue());
            if (this._md) { this._md.render(this.getValue()); return this._md.getHtml(); }
            return '';
        }

        focus() {
            this.$ta.trigger('focus');
            return this;
        }

        enable() {
            this.$ta.prop('disabled', false);
            this.$wrap.removeClass('ts-me-disabled');
            return this;
        }

        disable() {
            this.$ta.prop('disabled', true);
            this.$wrap.removeClass('ts-me-focused').addClass('ts-me-disabled');
            return this;
        }

        destroy() {
            const self = this;
            if (self._failed) return this;

            clearTimeout(self._timer);
            $(document).off(`keydown.${self._ns}`);
            self.fullscreen(false);

            if (self.$toolbarEl) {
                const tb = self.$toolbarEl.data('__pluginToolbar');
                if (tb && typeof tb.destroy === 'function') tb.destroy();
                self.$toolbarEl.off(`.${EVENT_NS}`);
            }
            if (self._md && typeof self._md.destroy === 'function') self._md.destroy();

            self.$ta.off(`.${EVENT_NS}`).removeClass('ts-me-input').css('height', '');
            self.$write.off(`.${EVENT_NS}`);
            self.$ta.insertBefore(self.$wrap);
            self.$wrap.remove();
            self.$el.removeData(instanceName);

            instanceCount--;
            if (instanceCount <= 0) {
                instanceCount = 0;
                const styleEl = document.getElementById(STYLE_ID);
                if (styleEl) styleEl.remove();
            }

            return this;
        }
    }

    PluginMarkdownEditor.defaults = {
        placeholder:   'Write in Markdown\u2026',
        ariaLabel:     'Markdown editor',
        view:          'write',          // 'write' | 'split' | 'preview'
        minHeight:     420,              // number (px) or CSS length; height of split and preview panes
        // Toolbar keys. '|' separates groups, 'views' adds the view switcher,
        // 'spacer' pushes what follows to the right. Set to false for no toolbar.
        toolbar:       ['bold', 'italic', 'strike', 'code', '|', 'h2', 'h3', 'quote', '|', 'ul', 'ol', 'task', '|',
                        'link', 'image', 'codeblock', 'hr', 'table', 'spacer', 'views', 'fullscreen'],
        toolbarOptions: { ariaLabel: 'Markdown formatting', size: 'sm', full: true },
        preview:       true,             // false removes the preview pane and view switcher
        renderer:      null,             // function(markdown) returning HTML or a Promise of HTML, replaces PluginMarkdown
        sanitize:      true,             // forwarded to PluginMarkdown
        breaks:        false,            // forwarded to PluginMarkdown
        previewDelay:  150,              // ms debounce before the preview refreshes
        syncScroll:    true,             // keep write and preview aligned in split view
        autoGrow:      true,             // write view grows with the text like a document
        tabSize:       2,                // spaces used to indent list items
        statusBar:     true,             // word count and reading time
        imageUpload:   null,             // function(file) {} returning a URL or a Promise of one
        disabled:      false,
        onChange:      null              // function(markdown) {}
    };

    $.extend(themestrap, { PluginMarkdownEditor });

    $.fn.themestrapPluginMarkdownEditor = function(opts) {
        return this.map(function() {
            const $this = $(this);

            if ($this.data(instanceName)) {
                return $this.data(instanceName);
            } else {
                return new PluginMarkdownEditor($this, opts);
            }
        });
    };

})).apply(this, [window.themestrap, jQuery]);