Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
blog
/
assets
/
admin
/
themestrap
:
themestrap.plugin.markdowneditor.js
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
/** * 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, ``, 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, ``); 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]);