/** * Themestrap RichEditor * * Lightweight contenteditable editor built on PluginToolbar (formatting controls) * and PluginDialog. * * Markup: * * <div data-plugin-richeditor * data-plugin-options='{"placeholder": "Write your message...", "minHeight": 220}'> * <p>Optional seed content.</p> * </div> * * Blog mode: * * <div data-plugin-richeditor * data-plugin-options='{"toolbar": "blog", "markdownShortcuts": true, "statusBar": true}'></div> * * "toolbar": "blog" adds headings, quote, inline code, code block, divider and image. * Typing Markdown shortcuts converts as you write: "## " heading, "- " list, "1. " numbered * list, "> " quote, "```" code block, "---" divider, plus **bold**, *italic* and `code`. * getMarkdown() / setMarkdown() let the editor read and write Markdown, so a post stays a * plain .md file. Images can be pasted, dropped or picked when imageUpload is provided. * * Public API (via stored instance): * const editor = $('#myEditor').data('__pluginRichEditor'); * editor.getMarkdown(); // Markdown string converted from the current content * editor.setMarkdown(md); // renders Markdown into the editor (PluginMarkdown or options.renderer) * editor.getWordCount(); * editor.getContent(); // HTML string, '' when empty * editor.getText(); // plain text * editor.setContent(html); // replaces content (trusted HTML only, it is not sanitized) * editor.isEmpty(); * editor.clear(); * editor.focus(); * editor.enable() / editor.disable(); * editor.destroy(); // restores the current content into the host element * * Events dispatched on the host element (native CustomEvent, bubbles): * ts.richeditor.change detail: { instance } * ts.richeditor.focus detail: { instance } * ts.richeditor.blur detail: { instance } * ts.richeditor.save detail: { instance } Ctrl/Cmd+S pressed * ts.richeditor.upload detail: { instance, file, url } * ts.richeditor.error detail: { instance, error } * * CSS custom properties (all optional): * --ts-re-bg, --ts-re-border, --ts-re-border-focus, --ts-re-radius, --ts-re-padding, * --ts-re-toolbar-bg, --ts-re-text, --ts-re-placeholder, --ts-re-link, * --ts-re-accent-contrast * * Init.js wiring: * if ($.isFunction($.fn['themestrapPluginRichEditor']) && $('[data-plugin-richeditor]').length) { * themestrap.fn.intObsInit('[data-plugin-richeditor]:not(.manual)', 'themestrapPluginRichEditor'); * } */ // Rich Editor (((themestrap = {}, $) => { const instanceName = '__pluginRichEditor'; const STYLE_ID = 'ts-richeditor-styles'; const EVENT_NS = 'richeditor'; let instanceCount = 0; let seq = 0; const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ''); const MOD = IS_MAC ? '\u2318' : 'Ctrl+'; // Theme tokens. Private --re-* properties resolve the public --ts-re-* overrides // against a per-theme fallback, so consumers can override on any ancestor. const LIGHT_TOKENS = ` --re-bg: var(--ts-re-bg, #ffffff); --re-border: var(--ts-re-border, rgba(0,0,0,.14)); --re-toolbar-bg: var(--ts-re-toolbar-bg, rgba(0,0,0,.035)); --re-text: var(--ts-re-text, #1f2937); --re-placeholder: var(--ts-re-placeholder, rgba(31,41,55,.42)); --re-link: var(--ts-re-link, #0b8a83); --re-accent: var(--ts-re-border-focus, #00a89f); `; const DARK_TOKENS = ` --re-bg: var(--ts-re-bg, rgba(255,255,255,.02)); --re-border: var(--ts-re-border, rgba(255,255,255,.10)); --re-toolbar-bg: var(--ts-re-toolbar-bg, rgba(0,0,0,.20)); --re-text: var(--ts-re-text, #cdd8e6); --re-placeholder: var(--ts-re-placeholder, rgba(205,216,230,.35)); --re-link: var(--ts-re-link, #00c7be); --re-accent: var(--ts-re-border-focus, #00c7be); `; const INJECTED_CSS = ` .ts-richeditor { ${LIGHT_TOKENS} } @media (prefers-color-scheme: dark) { .ts-richeditor { ${DARK_TOKENS} } } html.dark .ts-richeditor, [data-bs-theme="dark"] .ts-richeditor { ${DARK_TOKENS} } [data-bs-theme="light"] .ts-richeditor, html [data-bs-theme="light"] .ts-richeditor { ${LIGHT_TOKENS} } .ts-richeditor { display: flex; flex-direction: column; background: var(--re-bg); border: 1px solid var(--re-border); border-radius: var(--ts-re-radius, 6px); overflow: hidden; transition: border-color .15s, box-shadow .15s; } .ts-richeditor.ts-re-focused { border-color: var(--re-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--re-accent) 18%, transparent); } .ts-richeditor.ts-re-disabled { opacity: .6; } .ts-richeditor.ts-re-disabled .ts-toolbar { pointer-events: none; } /* PluginToolbar shell variables are overridden so the toolbar merges flush into the top edge of the editor container. */ .ts-richeditor .ts-toolbar { --ts-tb-bg: var(--re-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(--re-border); } .ts-re-content { flex: 1; padding: var(--ts-re-padding, 14px 16px); outline: none; color: var(--re-text); font-size: 14px; line-height: 1.7; font-family: inherit; overflow-y: auto; word-break: break-word; } /* Placeholder is floated with zero height so it never affects the caret line */ .ts-re-content.ts-re-empty::before { content: attr(data-placeholder); color: var(--re-placeholder); pointer-events: none; float: left; height: 0; } .ts-re-content a { color: var(--re-link); } .ts-re-content ul, .ts-re-content ol { padding-left: 1.4em; margin: .4em 0; } .ts-re-content p { margin: 0 0 .6em; } .ts-re-content p:last-child { margin-bottom: 0; } .ts-re-content h1, .ts-re-content h2, .ts-re-content h3 { line-height: 1.3; margin: 1.1em 0 .4em; } .ts-re-content h1 { font-size: 1.6em; } .ts-re-content h2 { font-size: 1.35em; } .ts-re-content h3 { font-size: 1.15em; } .ts-re-content h1:first-child, .ts-re-content h2:first-child, .ts-re-content h3:first-child { margin-top: 0; } .ts-re-content blockquote { margin: 0 0 .8em; padding-left: 1em; border-left: 3px solid var(--re-accent); color: var(--re-placeholder); } .ts-re-content pre { background: color-mix(in srgb, currentColor 8%, transparent); padding: 10px 12px; border-radius: 6px; overflow-x: auto; margin: 0 0 .8em; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; white-space: pre-wrap; } .ts-re-content code { background: color-mix(in srgb, currentColor 10%, transparent); padding: .1em .35em; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; } .ts-re-content pre code { background: none; padding: 0; } .ts-re-content hr { border: 0; border-top: 1px solid var(--re-border); margin: 1.2em 0; } .ts-re-content hr[data-more] { border-top: 1px dashed var(--re-accent); overflow: visible; height: 0; margin: 1.6em 0; } .ts-re-content hr[data-more]::after { content: 'Summary ends here'; position: relative; top: -.75em; display: table; margin: 0 auto; padding: 0 .6em; background: var(--re-bg); color: var(--re-accent); font-size: 11px; letter-spacing: .04em; text-transform: uppercase; } .ts-re-content img { max-width: 100%; height: auto; border-radius: 4px; } .ts-re-content table { border-collapse: collapse; margin-bottom: .8em; } .ts-re-content th, .ts-re-content td { border: 1px solid var(--re-border); padding: 4px 10px; } .ts-re-status { display: flex; gap: 14px; padding: 6px 14px; border-top: 1px solid var(--re-border); background: var(--re-toolbar-bg); color: var(--re-placeholder); font-size: 12px; } .ts-re-status .ts-re-msg { margin-left: auto; } .ts-richeditor.ts-re-dropping .ts-re-content { outline: 2px dashed var(--re-accent); outline-offset: -8px; } /* Link dialog. The dialog lives on <body>, so it is themed with currentColor and inherits its colors from PluginDialog's panel. */ .ts-re-link-label { display: block; font-size: 11px; font-weight: 700; letter-spacing: .10em; text-transform: uppercase; opacity: .65; margin-bottom: 8px; } .ts-re-link-input { display: block; width: 100%; background: transparent; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 5px; color: inherit; font-size: 14px; padding: 9px 12px; font-family: inherit; outline: none; transition: border-color .15s; } .ts-re-link-input:focus { border-color: var(--ts-re-border-focus, #00a89f); } .ts-re-link-input[aria-invalid="true"] { border-color: #dc2626; } .ts-re-link-error { color: #dc2626; font-size: 12px; margin: 6px 0 0; } .ts-re-link-btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } .ts-re-dlg-remove { margin-right: auto; } .ts-re-dlg-cancel, .ts-re-dlg-remove, .ts-re-dlg-confirm { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 5px; border: none; font-size: 13px; font-family: inherit; font-weight: 500; cursor: pointer; transition: opacity .14s; } .ts-re-dlg-cancel, .ts-re-dlg-remove { background: color-mix(in srgb, currentColor 10%, transparent); color: inherit; } .ts-re-dlg-confirm { background: var(--ts-re-border-focus, #00a89f); color: var(--ts-re-accent-contrast, #04211f); font-weight: 600; } .ts-re-dlg-cancel:hover, .ts-re-dlg-remove:hover { opacity: .8; } .ts-re-dlg-confirm:hover { opacity: .87; } .ts-re-dlg-remove[hidden] { display: none; } `; // Command definitions: key -> execCommand name, icon, label, and optional shortcut letter const CMDS = { bold: { cmd: 'bold', icon: 'fas fa-bold', label: 'Bold', key: 'B' }, italic: { cmd: 'italic', icon: 'fas fa-italic', label: 'Italic', key: 'I' }, underline: { cmd: 'underline', icon: 'fas fa-underline', label: 'Underline', key: 'U' }, strike: { cmd: 'strikeThrough', icon: 'fas fa-strikethrough', label: 'Strikethrough' }, ul: { cmd: 'insertUnorderedList', icon: 'fas fa-list-ul', label: 'Bullet List' }, ol: { cmd: 'insertOrderedList', icon: 'fas fa-list-ol', label: 'Numbered List' }, link: { cmd: 'link', icon: 'fas fa-link', label: 'Insert Link', key: 'K' }, clearFormat: { cmd: 'removeFormat', icon: 'fas fa-eraser', label: 'Clear Formatting' }, // Blog commands. 'inlineCode' is a custom toggle and the rest are one-shot actions. inlineCode: { cmd: 'inlineCode', icon: 'fas fa-code', label: 'Inline Code' }, h2: { cmd: 'h2', icon: 'fas fa-heading', label: 'Heading', text: 'H2' }, h3: { cmd: 'h3', icon: 'fas fa-heading', label: 'Subheading', text: 'H3' }, quote: { cmd: 'quote', icon: 'fas fa-quote-left', label: 'Quote' }, codeBlock: { cmd: 'codeBlock', icon: 'fas fa-file-code', label: 'Code Block' }, hr: { cmd: 'insertHorizontalRule', icon: 'fas fa-minus', label: 'Divider' }, image: { cmd: 'image', icon: 'fas fa-image', label: 'Insert Image' }, }; const INLINE_KEYS = ['bold', 'italic', 'underline', 'strike', 'inlineCode']; const LIST_KEYS = ['ul', 'ol']; const BLOCK_KEYS = ['h2', 'h3', 'quote', 'codeBlock', 'hr']; const ACTION_KEYS = ['link', 'image', 'clearFormat']; // Named toolbar presets. options.toolbar may be one of these names or an array of keys. const TOOLBARS = { basic: ['bold', 'italic', 'underline', 'strike', '|', 'ul', 'ol', '|', 'link', 'clearFormat'], blog: ['bold', 'italic', 'strike', 'inlineCode', '|', 'h2', 'h3', 'quote', '|', 'ul', 'ol', '|', 'codeBlock', 'hr', '|', 'link', 'image', 'clearFormat'] }; const escapeHtml = (str) => String(str).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const toPx = (v) => (typeof v === 'number' ? `${v}px` : v); const titleFor = (def) => (def.key ? `${def.label} (${MOD}${def.key})` : def.label); // Returns a safe URL string, or '' when the input is rejected. // Bare addresses get https:// and bare emails get mailto:, anything with a // scheme outside the allowed list (javascript:, data:, ...) is refused. const normalizeUrl = (raw, protocols) => { let url = String(raw || '').replace(/[\u0000-\u001f\u007f\s]+/g, ''); if (!url) return ''; if (/^[\/#?]/.test(url)) return url; if (/^[^@:\/]+@[^@:\/]+\.[^@:\/]+$/.test(url)) return `mailto:${url}`; if (!/^[a-z][a-z0-9+.\-]*:/i.test(url)) url = `https://${url}`; const scheme = url.match(/^([a-z][a-z0-9+.\-]*):/i)[1].toLowerCase(); return protocols.indexOf(scheme) !== -1 ? url : ''; }; // HTML to Markdown. Handles what the editor can produce plus common pasted markup. const mdEscape = (t) => t .replace(/\\/g, '\\\\') .replace(/([*`\[\]])/g, '\\$1') .replace(/(^|\s)_|_(?=\s|$)/g, (m) => m.replace('_', '\\_')); const htmlToMarkdown = (root) => { const inline = (node) => { let out = ''; node.childNodes.forEach((n) => { out += inl(n); }); return out; }; const wrapI = (mark, n) => { const inner = inline(n); const m = inner.match(/^(\s*)([\s\S]*?)(\s*)$/); return m[2] ? `${m[1]}${mark}${m[2]}${mark}${m[3]}` : inner; }; const inl = (n) => { if (n.nodeType === 3) return mdEscape(n.nodeValue.replace(/\u00a0/g, ' ').replace(/\s*\n\s*/g, ' ')); if (n.nodeType !== 1) return ''; const tag = n.tagName.toLowerCase(); switch (tag) { case 'strong': case 'b': return wrapI('**', n); case 'em': case 'i': return wrapI('*', n); case 'del': case 's': case 'strike': return wrapI('~~', n); case 'code': { const t = n.textContent; const fence = t.indexOf('`') !== -1 ? '``' : '`'; return t ? `${fence}${t}${fence}` : ''; } case 'br': return ' \n'; case 'a': { const href = n.getAttribute('href') || ''; const text = inline(n); return href ? `[${text}](${href})` : text; } case 'img': { const src = n.getAttribute('src') || ''; return src ? `![${(n.getAttribute('alt') || '').replace(/[\[\]]/g, '')}](${src})` : ''; } case 'input': return n.checked ? '[x] ' : '[ ] '; default: return inline(n); } }; const blockEsc = (t) => t.replace(/^(\s*)(#{1,6}\s|>|[-+]\s|\d+[.)]\s|={3,}|-{3,})/, '$1\\$2'); const list = (el, depth) => { const ordered = el.tagName.toLowerCase() === 'ol'; let i = parseInt(el.getAttribute('start') || '1', 10); let out = ''; Array.prototype.forEach.call(el.children, (li) => { if (li.tagName.toLowerCase() !== 'li') return; const pad = ' '.repeat(depth); const mark = ordered ? `${i++}. ` : '- '; let text = ''; let nested = ''; li.childNodes.forEach((c) => { const t = c.nodeType === 1 ? c.tagName.toLowerCase() : ''; if (t === 'ul' || t === 'ol') nested += list(c, depth + 1); else if (t === 'p' || t === 'div') text += (text ? ' ' : '') + inline(c).trim(); else text += inl(c); }); out += `${pad}${mark}${text.trim()}\n${nested}`; }); return out; }; const block = (n) => { if (n.nodeType === 3) { const t = n.nodeValue.replace(/\u00a0/g, ' ').replace(/\s+/g, ' '); return t.trim() ? blockEsc(mdEscape(t.trim())) + '\n\n' : ''; } if (n.nodeType !== 1) return ''; const tag = n.tagName.toLowerCase(); const h = tag.match(/^h([1-6])$/); if (h) return `${'#'.repeat(+h[1])} ${inline(n).trim()}\n\n`; switch (tag) { case 'p': case 'div': { const kids = Array.prototype.slice.call(n.children); // A div that wraps blocks is a container, not a paragraph if (kids.some((k) => /^(p|div|ul|ol|h[1-6]|pre|blockquote|table|hr)$/i.test(k.tagName))) return children(n); const t = inline(n).replace(/( \n)+$/, '').trim(); return t ? blockEsc(t) + '\n\n' : ''; } case 'ul': case 'ol': return list(n, 0) + '\n'; case 'blockquote': { const inner = children(n).trim().split('\n').map((l) => (l ? `> ${l}` : '>')).join('\n'); return inner + '\n\n'; } case 'pre': { const code = n.querySelector('code'); const lang = code ? ((code.className.match(/language-([\w+#.-]+)/) || [])[1] || '') : ''; const text = (code || n).textContent.replace(/\n$/, ''); const fence = text.indexOf('```') !== -1 ? '````' : '```'; return `${fence}${lang}\n${text}\n${fence}\n\n`; } case 'hr': return n.hasAttribute('data-more') ? '<!--more-->\n\n' : '---\n\n'; case 'table': { const rows = Array.prototype.map.call(n.querySelectorAll('tr'), (tr) => Array.prototype.map.call(tr.children, (c) => inline(c).trim().replace(/\|/g, '\\|').replace(/\n/g, ' '))); if (!rows.length) return ''; const w = Math.max.apply(null, rows.map((r) => r.length)); const fix = (r) => `| ${r.concat(Array(w - r.length).fill('')).join(' | ')} |`; return [fix(rows[0]), `| ${Array(w).fill('---').join(' | ')} |`].concat(rows.slice(1).map(fix)).join('\n') + '\n\n'; } case 'img': return inl(n) + '\n\n'; case 'script': case 'style': return ''; default: return children(n); } }; const children = (el) => { let out = ''; let run = ''; const flush = () => { if (run.trim()) out += blockEsc(run.trim()) + '\n\n'; run = ''; }; el.childNodes.forEach((c) => { const t = c.nodeType === 1 ? c.tagName.toLowerCase() : ''; const isInline = c.nodeType === 3 || /^(a|b|strong|i|em|s|del|strike|u|code|span|br|img|sub|sup|mark|small|input)$/.test(t); if (isInline) run += inl(c); else { flush(); out += block(c); } }); flush(); return out; }; return children(root).replace(/\n{3,}/g, '\n\n').trim() + '\n'; }; class PluginRichEditor { 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._savedRange = null; this._linkEl = null; this._selectedText = ''; this._failed = false; this .setData() .setOptions(opts) .build() .events(); return this; } setData() { this.$el.data(instanceName, this); return this; } setOptions(opts) { this.options = $.extend(true, {}, PluginRichEditor.defaults, opts, { wrapper: this.$el }); // $.extend(true) merges arrays index by index, so a short user array would be // padded with default entries. Arrays are replaced wholesale instead. if (opts && Object.prototype.hasOwnProperty.call(opts, 'toolbar')) { this.options.toolbar = typeof opts.toolbar === 'string' && TOOLBARS[opts.toolbar] ? TOOLBARS[opts.toolbar].slice() : opts.toolbar; } if (opts && typeof opts.imageUpload === 'function') this.options.imageUpload = opts.imageUpload; if (opts && typeof opts.renderer === 'function') this.options.renderer = opts.renderer; if (opts && Array.isArray(opts.allowedProtocols)) { this.options.allowedProtocols = opts.allowedProtocols.slice(); } return this; } build() { const self = this; const opts = self.options; const $el = opts.wrapper; self._enabled = self._enabledKeys(); // Dependencies are only required for the features that are switched on if ((self._enabled.size && !self._requires('themestrapPluginToolbar', 'PluginToolbar')) || ((self._enabled.has('link') || (self._enabled.has('image') && typeof opts.imageUpload !== 'function')) && !self._requires('themestrapPluginDialog', 'PluginDialog'))) { self._failed = true; $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); } // Any markup already inside the host becomes the initial content const seed = $el.html().trim(); $el.empty().addClass('ts-richeditor'); if (self._enabled.size) { self.$toolbarEl = self._buildToolbarEl(); $el.append(self.$toolbarEl); self.$toolbarEl.themestrapPluginToolbar($.extend({}, opts.toolbarOptions)); self._toolbar = self.$toolbarEl.data('__pluginToolbar'); } self.$editable = $('<div class="ts-re-content ts-re-empty" contenteditable="true" spellcheck="true" role="textbox" aria-multiline="true"></div>') .attr('aria-label', opts.ariaLabel); if (opts.minHeight) self.$editable.css('min-height', toPx(opts.minHeight)); if (opts.maxHeight) self.$editable.css('max-height', toPx(opts.maxHeight)); if (opts.placeholder) { self.$editable.attr({ 'data-placeholder': opts.placeholder, 'aria-placeholder': opts.placeholder }); } if (seed) self.$editable.html(seed); $el.append(self.$editable); self._toggleEmpty(); if (opts.statusBar) { self.$status = $('<div class="ts-re-status"><span class="ts-re-words"></span><span class="ts-re-time"></span><span class="ts-re-msg" role="status"></span></div>'); $el.append(self.$status); self._updateStatus(); } if (self._enabled.has('link') || (self._enabled.has('image') && typeof opts.imageUpload !== 'function')) { self._buildDialog(); } if (opts.disabled) self.disable(); return this; } // Verifies a dependency plugin is loaded, reporting through the framework when it is not _requires(method, label) { if (typeof $.fn[method] === 'function') return true; const file = `components/themestrap.plugin.${label.replace('Plugin', '').toLowerCase()}.js`; if (themestrap.fn && typeof themestrap.fn.showErrorMessage === 'function') { themestrap.fn.showErrorMessage( 'Failed to Load File', `Failed to load: ${label} - Include the following file(s): (${file})` ); } else { console.error(`PluginRichEditor: ${label} must be loaded first (${file}).`); } return false; } // Set of enabled command keys derived from options.toolbar _enabledKeys() { const t = this.options.toolbar; if (t === false) return new Set(); if (t === true || !Array.isArray(t)) return new Set(Object.keys(CMDS)); const keys = new Set(); t.forEach((item) => { if (!item || item === '|') return; if (CMDS[item]) { keys.add(item); return; } // Accept raw execCommand names for backwards compatibility const byCmd = Object.keys(CMDS).find((k) => CMDS[k].cmd === item); if (byCmd) keys.add(byCmd); }); return keys; } // PluginToolbar host element and its inner markup _buildToolbarEl() { const self = this; const enabled = self._enabled; const inline = INLINE_KEYS.filter((k) => enabled.has(k)); const lists = LIST_KEYS.filter((k) => enabled.has(k)); const blocks = BLOCK_KEYS.filter((k) => enabled.has(k)); const actions = ACTION_KEYS.filter((k) => enabled.has(k)); const buttons = (keys) => keys.map((k) => { const d = CMDS[k]; return ` <button type="button" class="ts-tb-icon" data-toolbar-button data-toolbar-value="${k}" title="${titleFor(d)}" aria-label="${d.label}"> ${d.text ? `<span style="font-size:11px;font-weight:700">${d.text}</span>` : `<i class="${d.icon}" aria-hidden="true"></i>`} </button>`; }).join(''); const groups = []; if (inline.length) groups.push(self._toggleGroupHtml(inline, 'Inline formatting')); if (blocks.length) groups.push(buttons(blocks)); if (lists.length) groups.push(self._toggleGroupHtml(lists, 'List formatting')); if (actions.length) groups.push(buttons(actions)); return $(`<div>${groups.join('<hr data-toolbar-separator>')}</div>`); } _toggleGroupHtml(keys, label) { const items = keys.map((k) => { const d = CMDS[k]; return ` <button type="button" class="ts-tb-icon" data-toolbar-toggle-item data-toolbar-value="${k}" title="${titleFor(d)}" aria-label="${d.label}"> <i class="${d.icon}" aria-hidden="true"></i> </button>`; }).join(''); return ` <div data-toolbar-toggle-group data-toolbar-type="multiple" data-toolbar-group-label="${label}">${items}</div>`; } // Link dialog. PluginDialog owns the modal UX, focus trap, ARIA, and base CSS. _buildDialog() { const self = this; const id = `ts-re-dlg-${self._uid}`; self.$dialog = $(` <div data-plugin-dialog id="${id}"> <div data-dialog-backdrop></div> <div data-dialog-panel class="dialog-sm"> <h2 data-dialog-title>Insert Link</h2> <p data-dialog-description>Paste or type a URL. Select text first to wrap it in the link.</p> <label class="ts-re-link-label" for="${id}-url">URL</label> <input type="text" id="${id}-url" class="ts-re-link-input" inputmode="url" placeholder="https://" autocomplete="off" spellcheck="false"> <p class="ts-re-link-error" role="alert" hidden></p> <div class="ts-re-link-btns"> <button type="button" class="ts-re-dlg-remove" hidden>Remove link</button> <button type="button" class="ts-re-dlg-cancel" data-dialog-close>Cancel</button> <button type="button" class="ts-re-dlg-confirm"> <i class="fas fa-link" aria-hidden="true"></i> <span>Insert</span> </button> </div> </div> </div> `); $('body').append(self.$dialog); self.$dialog.themestrapPluginDialog($.extend({}, self.options.dialogOptions)); self._dialog = self.$dialog.data('__pluginDialog'); self.$linkInput = self.$dialog.find('.ts-re-link-input'); self.$linkError = self.$dialog.find('.ts-re-link-error'); self.$linkRemove = self.$dialog.find('.ts-re-dlg-remove'); self.$linkTitle = self.$dialog.find('[data-dialog-title]'); self.$linkOk = self.$dialog.find('.ts-re-dlg-confirm span'); return this; } events() { const self = this; if (self._failed) return this; if (self.$toolbarEl) { // Save the selection and keep focus in the editable while the toolbar is // pressed. The click (and its toolbar:* event) still fires normally. self.$toolbarEl.on(`mousedown.${EVENT_NS}`, (e) => { self._saveSelection(); e.preventDefault(); }); // PluginToolbar dispatches native CustomEvents, so plain listeners are used self.$toolbarEl[0].addEventListener('toolbar:toggle', (e) => { self._execCmd(e.detail.value); }); self.$toolbarEl[0].addEventListener('toolbar:button', (e) => { if (e.detail.value === 'link') { self._openLinkDialog(); } else if (e.detail.value === 'image') { self._insertImage(); } else { self._execCmd(e.detail.value); } }); } self.$editable .on(`focus.${EVENT_NS}`, () => { self.options.wrapper.addClass('ts-re-focused'); // Paragraphs instead of bare <div>s so the p rules in the injected CSS apply try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch (err) { /* unsupported */ } self._dispatch('ts.richeditor.focus'); if (typeof self.options.onFocus === 'function') self.options.onFocus.call(self); }) .on(`blur.${EVENT_NS}`, () => { self._saveSelection(); self.options.wrapper.removeClass('ts-re-focused'); self._dispatch('ts.richeditor.blur'); if (typeof self.options.onBlur === 'function') self.options.onBlur.call(self); }) .on(`keyup.${EVENT_NS} mouseup.${EVENT_NS}`, () => { self._syncState(); }) .on(`input.${EVENT_NS}`, (e) => { self._toggleEmpty(); self._syncState(); self._updateStatus(); if (self.options.markdownShortcuts) self._inlineShortcut(e.originalEvent); self._dispatch('ts.richeditor.change'); if (typeof self.options.onChange === 'function') { self.options.onChange.call(self, self.getContent()); } }) .on(`keydown.${EVENT_NS}`, (e) => { const mod = e.ctrlKey || e.metaKey; if (mod && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 's') { e.preventDefault(); self._dispatch('ts.richeditor.save'); return; } if (self.options.markdownShortcuts && !mod && !e.altKey && !e.isComposing && (e.key === ' ' || e.key === 'Enter') && self._blockShortcut(e)) { e.preventDefault(); return; } if (mod && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 'k' && self._enabled.has('link')) { e.preventDefault(); self._openLinkDialog(); } }) .on(`paste.${EVENT_NS}`, (e) => { const data = (e.originalEvent || e).clipboardData; if (!data) return; const files = Array.prototype.filter.call(data.files || [], (f) => /^image\//.test(f.type)); if (files.length && typeof self.options.imageUpload === 'function') { e.preventDefault(); self._uploadFiles(files); return; } if (!self.options.pastePlainText) return; e.preventDefault(); const text = data.getData('text/plain'); // A bare URL pasted over a selection becomes a link if (/^https?:\/\/\S+$/.test(text.trim()) && window.getSelection().toString()) { document.execCommand('createLink', false, text.trim()); self._decorateLinks(); } else { document.execCommand('insertText', false, text); } }) .on(`dragover.${EVENT_NS} dragenter.${EVENT_NS}`, (e) => { const dt = e.originalEvent.dataTransfer; if (typeof self.options.imageUpload !== 'function' || !dt || !Array.prototype.some.call(dt.items || [], (i) => /^image\//.test(i.type))) return; e.preventDefault(); self.options.wrapper.addClass('ts-re-dropping'); }) .on(`dragleave.${EVENT_NS} drop.${EVENT_NS}`, () => self.options.wrapper.removeClass('ts-re-dropping')) .on(`drop.${EVENT_NS}`, (e) => { const dt = e.originalEvent.dataTransfer; const files = dt ? Array.prototype.filter.call(dt.files || [], (f) => /^image\//.test(f.type)) : []; if (!files.length || typeof self.options.imageUpload !== 'function') return; e.preventDefault(); self._uploadFiles(files); }); // selectionchange only fires on the document, never on an element $(document).on(`selectionchange.${self._ns}`, () => { if (document.activeElement === self.$editable[0]) self._syncState(); }); if (self.$dialog) self._dialogEvents(); return this; } _dialogEvents() { const self = this; const $dlg = self.$dialog; $dlg.on(`dialog:open.${EVENT_NS}`, () => { const editing = !!self._linkEl; const image = !!self._imageMode; self._setLinkError(''); self.$linkInput.val(editing ? (self._linkEl.getAttribute('href') || '') : ''); self.$linkRemove.prop('hidden', !editing); self.$linkTitle.text(image ? 'Insert Image' : (editing ? 'Edit Link' : 'Insert Link')); self.$dialog.find('[data-dialog-description]').text(image ? 'Paste the address of an image.' : 'Paste or type a URL. Select text first to wrap it in the link.'); self.$linkOk.text(editing ? 'Update' : 'Insert'); setTimeout(() => self.$linkInput.trigger('focus').trigger('select'), 60); }); $dlg.on(`dialog:close.${EVENT_NS}`, () => { self._imageMode = false; }); $dlg.on(`keydown.${EVENT_NS}`, '.ts-re-link-input', (e) => { if (e.key === 'Enter') { e.preventDefault(); self._applyLink(); } }); $dlg.on(`input.${EVENT_NS}`, '.ts-re-link-input', () => self._setLinkError('')); $dlg.on(`click.${EVENT_NS}`, '.ts-re-dlg-confirm', () => self._applyLink()); $dlg.on(`click.${EVENT_NS}`, '.ts-re-dlg-remove', () => self._removeLink()); return this; } // Selection helpers _inEditable(range) { return !!range && this.$editable[0].contains(range.commonAncestorContainer); } // The live selection range when it sits inside the editable, otherwise the saved one _activeRange() { const sel = window.getSelection(); if (sel && sel.rangeCount) { const range = sel.getRangeAt(0); if (this._inEditable(range)) return range.cloneRange(); } return this._savedRange ? this._savedRange.cloneRange() : null; } _saveSelection() { const sel = window.getSelection(); if (sel && sel.rangeCount) { const range = sel.getRangeAt(0); if (this._inEditable(range)) this._savedRange = range.cloneRange(); } return this; } _restoreSelection() { if (!this._savedRange) return this; const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(this._savedRange); } return this; } _selectNode(node) { const range = document.createRange(); range.selectNodeContents(node); this._savedRange = range; return this._restoreSelection(); } // Format commands _execCmd(key) { const def = CMDS[key]; if (!def) return this; this._restoreSelection(); this.$editable.trigger('focus'); switch (def.cmd) { case 'h2': case 'h3': this._toggleBlock(def.cmd); break; case 'quote': this._toggleBlock('blockquote'); break; case 'codeBlock': this._toggleBlock('pre'); break; case 'inlineCode': this._toggleInlineCode(); break; default: document.execCommand(def.cmd, false, null); } this._syncState(); this._afterEdit(); return this; } // Applies a block tag, or returns to a paragraph when it is already applied _toggleBlock(tag) { let current = ''; try { current = String(document.queryCommandValue('formatBlock') || '').toLowerCase().replace(/[<>]/g, ''); } catch (err) { /* unsupported */ } document.execCommand('formatBlock', false, current === tag ? 'p' : tag); return this; } _toggleInlineCode() { const sel = window.getSelection(); if (!sel || !sel.rangeCount) return this; const range = sel.getRangeAt(0); const node = range.commonAncestorContainer; const el = node.nodeType === 3 ? node.parentElement : node; const code = el && el.closest ? el.closest('code') : null; if (code && this.$editable[0].contains(code) && code.parentElement.tagName !== 'PRE') { const text = document.createTextNode(code.textContent); code.replaceWith(text); const r = document.createRange(); r.selectNodeContents(text); sel.removeAllRanges(); sel.addRange(r); } else if (!range.collapsed) { document.execCommand('insertHTML', false, `<code>${escapeHtml(range.toString())}</code>`); } return this; } // Fires change after programmatic edits, which do not always raise an input event _afterEdit() { this._toggleEmpty(); this._updateStatus(); this._dispatch('ts.richeditor.change'); if (typeof this.options.onChange === 'function') this.options.onChange.call(this, this.getContent()); return this; } // Markdown typing shortcuts // Block shortcuts fire on Space ("## ") or Enter ("```", "---") at the start of a line _blockShortcut(e) { const sel = window.getSelection(); if (!sel || !sel.rangeCount || !sel.isCollapsed) return false; const range = sel.getRangeAt(0); let block = range.startContainer; while (block && block !== this.$editable[0] && !(block.nodeType === 1 && /^(P|DIV)$/.test(block.tagName))) { block = block.parentNode; } if (!block || block === this.$editable[0]) { // Text typed directly in the editable root, with no wrapping paragraph yet if (range.startContainer.nodeType !== 3 || range.startContainer.parentNode !== this.$editable[0]) return false; block = null; } const text = block ? block.textContent : range.startContainer.nodeValue; const upTo = block ? this._textBefore(block, range) : text.slice(0, range.startOffset); const clear = () => { if (block) { block.innerHTML = '<br>'; } else { range.startContainer.nodeValue = text.slice(upTo.length); } const r = document.createRange(); r.selectNodeContents(block || range.startContainer.parentNode); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); }; if (e.key === ' ') { if (upTo !== text.slice(0, upTo.length) || text.trim() !== upTo.trim()) return false; let m; if ((m = upTo.match(/^(#{1,3})$/))) { clear(); document.execCommand('formatBlock', false, 'h' + m[1].length); } else if (upTo === '>') { clear(); document.execCommand('formatBlock', false, 'blockquote'); } else if (/^[-*+]$/.test(upTo)) { clear(); document.execCommand('insertUnorderedList'); } else if (/^\d+[.)]$/.test(upTo)) { clear(); document.execCommand('insertOrderedList'); } else return false; this._afterEdit(); return true; } if (e.key === 'Enter') { if (/^`{3}[\w+#.-]*$/.test(upTo) && text.trim() === upTo) { clear(); document.execCommand('formatBlock', false, 'pre'); this._afterEdit(); return true; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(upTo) && text.trim() === upTo) { clear(); document.execCommand('insertHorizontalRule'); this._afterEdit(); return true; } } return false; } _textBefore(block, range) { const r = document.createRange(); r.selectNodeContents(block); r.setEnd(range.startContainer, range.startOffset); return r.toString(); } // **bold**, *italic*, ~~strike~~ and `code` convert when the closing mark is typed _inlineShortcut(ev) { if (!ev || ev.inputType !== 'insertText' || !/^[*_`~]$/.test(ev.data || '')) return; const sel = window.getSelection(); if (!sel || !sel.rangeCount || !sel.isCollapsed) return; const node = sel.anchorNode; if (!node || node.nodeType !== 3 || node.parentElement.closest('pre, code')) return; const before = node.nodeValue.slice(0, sel.anchorOffset); const rules = [ [/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, 'bold', 2], [/~~([^~\s](?:[^~]*[^~\s])?)~~$/, 'strikeThrough', 2], [/(?:^|[^*\w])\*([^*\s](?:[^*]*[^*\s])?)\*$/, 'italic', 1], [/(?:^|[^`])`([^`]+)`$/, 'code', 1] ]; for (let i = 0; i < rules.length; i++) { const m = before.match(rules[i][0]); if (!m) continue; const mark = rules[i][2]; const inner = m[1]; const end = sel.anchorOffset; const start = end - inner.length - mark * 2; const r = document.createRange(); r.setStart(node, start); r.setEnd(node, end); sel.removeAllRanges(); sel.addRange(r); if (rules[i][1] === 'code') document.execCommand('insertHTML', false, `<code>${escapeHtml(inner)}</code>&#8203;`); else { document.execCommand('insertText', false, inner); const back = document.createRange(); const n = sel.anchorNode; back.setStart(n, sel.anchorOffset - inner.length); back.setEnd(n, sel.anchorOffset); sel.removeAllRanges(); sel.addRange(back); document.execCommand(rules[i][1], false, null); sel.collapseToEnd(); // Typing continues unformatted after the converted text try { if (document.queryCommandState(rules[i][1])) document.execCommand(rules[i][1], false, null); } catch (err) { /* unsupported */ } } return; } } // Images _insertImage() { if (typeof this.options.imageUpload === 'function') { const self = this; const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.multiple = true; input.addEventListener('change', () => self._uploadFiles(input.files)); this._saveSelection(); input.click(); return this; } // Without an upload handler the link dialog asks for an image address this._imageMode = true; return this._openLinkDialog(); } _uploadFiles(fileList) { const self = this; this._restoreSelection(); Array.prototype.filter.call(fileList, (f) => /^image\//.test(f.type)).forEach((file) => { const alt = (file.name || 'image').replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image'; const id = `ts-re-up-${++seq}`; self.$editable.trigger('focus'); document.execCommand('insertHTML', false, `<p><img data-ts-uploading="${id}" alt="${escapeHtml(alt)}" style="opacity:.4" src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="></p>`); self.setStatus('Uploading image\u2026'); Promise.resolve() .then(() => self.options.imageUpload(file)) .then((url) => { const safe = normalizeUrl(url, ['http', 'https']); const img = self.$editable.find(`img[data-ts-uploading="${id}"]`)[0]; if (!safe) throw new Error('No image address returned'); if (img) { img.src = safe; img.removeAttribute('data-ts-uploading'); img.removeAttribute('style'); } self.setStatus('Image added'); self._dispatch('ts.richeditor.upload', { file, url: safe }); self._afterEdit(); }) .catch((err) => { self.$editable.find(`img[data-ts-uploading="${id}"]`).closest('p').remove(); self.setStatus('Upload failed'); self._dispatch('ts.richeditor.error', { error: err }); self._afterEdit(); }); }); return this; } // Link dialog flow _openLinkDialog() { const range = this._activeRange(); if (!range || !this._dialog) return this; this._savedRange = range; this._selectedText = range.toString().trim(); const node = range.commonAncestorContainer; const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; const a = el && typeof el.closest === 'function' ? el.closest('a') : null; this._linkEl = a && this.$editable[0].contains(a) ? a : null; if (this._imageMode) this._linkEl = null; this._dialog.open(); return this; } _setLinkError(message) { this.$linkError.text(message).prop('hidden', !message); this.$linkInput.attr('aria-invalid', message ? 'true' : 'false'); return this; } _applyLink() { const self = this; const raw = self.$linkInput.val().trim(); if (!raw) { if (self._linkEl) return self._removeLink(); self._dialog.close(); return this; } const url = normalizeUrl(raw, self._imageMode ? ['http', 'https'] : self.options.allowedProtocols); if (!url) { self._setLinkError('Enter a valid URL (' + self.options.allowedProtocols.join(', ') + ').'); self.$linkInput.trigger('focus'); return this; } self._restoreSelection(); self.$editable.trigger('focus'); if (self._imageMode) { self._imageMode = false; document.execCommand('insertHTML', false, `<img src="${escapeHtml(url)}" alt="">`); self._dialog.close(); self._afterEdit(); return this; } if (self._linkEl) { // Editing: retarget the whole existing link self._selectNode(self._linkEl); document.execCommand('createLink', false, url); } else if (self._selectedText) { document.execCommand('createLink', false, url); } else { document.execCommand('insertHTML', false, `<a href="${escapeHtml(url)}">${escapeHtml(raw)}</a>` ); } self._decorateLinks(); self._dialog.close(); self._afterEdit(); return this; } _removeLink() { if (this._linkEl) { this._restoreSelection(); this.$editable.trigger('focus'); this._selectNode(this._linkEl); document.execCommand('unlink', false, null); } this._dialog.close(); return this; } // Applies linkTarget and a safe rel to the anchors touched by the current selection _decorateLinks() { const target = this.options.linkTarget; const sel = window.getSelection(); if (!target || !sel || !sel.rangeCount) return this; this.$editable.find('a[href]').each(function() { if (!sel.containsNode(this, true)) return; this.setAttribute('target', target); this.setAttribute('rel', 'noopener noreferrer'); }); return this; } // State // Syncs PluginToolbar toggle items with the real formatting state. Only aria-pressed // and the toolbar class are written, so no commands are re-fired. _syncState() { if (!this.$toolbarEl) return this; this.$toolbarEl.find('[data-toolbar-toggle-item]').each(function() { const def = CMDS[this.getAttribute('data-toolbar-value')]; if (!def) return; let active = false; if (def.cmd === 'inlineCode') { const s = window.getSelection(); const n = s && s.anchorNode; const el = n && (n.nodeType === 3 ? n.parentElement : n); active = !!(el && el.closest && el.closest('code') && !el.closest('pre')); } else { try { active = !!document.queryCommandState(def.cmd); } catch (err) { /* unsupported */ } } this.setAttribute('aria-pressed', active ? 'true' : 'false'); this.classList.toggle('ts-toolbar__toggle-item--on', active); }); return this; } _toggleEmpty() { this.$editable.toggleClass('ts-re-empty', this.isEmpty()); return this; } _dispatch(name) { this.$el[0].dispatchEvent(new CustomEvent(name, { detail: { instance: this }, bubbles: true })); return this; } // Public API isEmpty() { return this.$editable.text().trim() === '' && !this.$editable.find('img, hr, li').length; } getContent() { return this.isEmpty() ? '' : this.$editable.html(); } getText() { return this.$editable.text(); } getWordCount() { return (this.getText().match(/[\p{L}\p{N}']+/gu) || []).length; } // Converts the current content to Markdown getMarkdown() { return this.isEmpty() ? '' : htmlToMarkdown(this.$editable[0]); } // Renders Markdown into the editor. Uses options.renderer, else PluginMarkdown (sanitized). setMarkdown(md) { md = md || ''; let html = ''; if (typeof this.options.renderer === 'function') { html = this.options.renderer(md); } else if (typeof $.fn.themestrapPluginMarkdown === 'function') { const $host = $('<div></div>'); $host.themestrapPluginMarkdown({ sanitize: true, highlight: false }); const inst = $host.data('__pluginMarkdown'); inst.render(md); html = inst.getHtml(); if (inst.destroy) inst.destroy(); } else { html = `<p>${escapeHtml(md).replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>')}</p>`; } // Task list checkboxes are display-only in the editor and round-trip through Markdown return this.setContent(html); } _updateStatus() { if (!this.$status) return this; const words = this.getWordCount(); this.$status.find('.ts-re-words').text(`${words} word${words === 1 ? '' : 's'}`); this.$status.find('.ts-re-time').text(`${Math.max(words ? 1 : 0, Math.ceil(words / 220))} min read`); return this; } setStatus(msg) { if (this.$status) this.$status.find('.ts-re-msg').text(msg || ''); return this; } setContent(html) { this.$editable.html(html || ''); this._toggleEmpty(); this._updateStatus(); return this; } clear() { return this.setContent(''); } focus() { this.$editable.trigger('focus'); return this; } enable() { this.$editable.attr('contenteditable', 'true').removeAttr('aria-disabled'); this.options.wrapper.removeClass('ts-re-disabled'); return this; } disable() { this.$editable.attr({ contenteditable: 'false', 'aria-disabled': 'true' }); this.options.wrapper.removeClass('ts-re-focused').addClass('ts-re-disabled'); return this; } destroy() { const self = this; if (self._failed) return this; $(document).off(`selectionchange.${self._ns}`); if (self._toolbar && typeof self._toolbar.destroy === 'function') { self._toolbar.destroy(); } if (self.$toolbarEl) { self.$toolbarEl.off(`.${EVENT_NS}`).remove(); } if (self._dialog && typeof self._dialog.destroy === 'function') { self._dialog.destroy(); } if (self.$dialog) { self.$dialog.off(`.${EVENT_NS}`).remove(); } // The current content is handed back to the host so a re-init keeps the user's work const html = self.getContent(); self.$editable.off(`.${EVENT_NS}`).remove(); if (self.$status) self.$status.remove(); self.options.wrapper .removeClass('ts-richeditor ts-re-focused ts-re-disabled') .html(html) .removeData(instanceName); instanceCount--; if (instanceCount <= 0) { instanceCount = 0; const styleEl = document.getElementById(STYLE_ID); if (styleEl) styleEl.remove(); } return this; } } PluginRichEditor.defaults = { placeholder: 'Write your message\u2026', ariaLabel: 'Rich text editor', minHeight: '220px', maxHeight: null, // Command keys. Legacy '|' separators are accepted but ignored, since grouping is automatic. // Set to false to render without a toolbar. toolbar: ['bold', 'italic', 'underline', 'strike', '|', 'ul', 'ol', '|', 'link', 'clearFormat'], toolbarOptions: { ariaLabel: 'Text formatting', size: 'sm', full: true }, dialogOptions: { animationIn: 'zoomIn', animationOut: 'zoomOut', closeOnBackdrop: true, scrollLock: false }, linkTarget: '_blank', // '' leaves links without a target attribute allowedProtocols: ['http', 'https', 'mailto', 'tel'], pastePlainText: true, // strips formatting from pasted content markdownShortcuts: false, // "## ", "- ", "> ", "```", "---", **bold**, *italic*, `code` convert as you type statusBar: false, // word count and reading time under the editor imageUpload: null, // function(file) returning a URL or a Promise of one; enables paste, drop and file picking renderer: null, // function(markdown) returning HTML, replaces PluginMarkdown in setMarkdown() disabled: false, onChange: null, // function(html) {} onFocus: null, // function() {} onBlur: null // function() {} }; PluginRichEditor.toolbars = TOOLBARS; $.extend(themestrap, { PluginRichEditor }); $.fn.themestrapPluginRichEditor = function(opts) { return this.map(function() { const $this = $(this); if ($this.data(instanceName)) { return $this.data(instanceName); } else { return new PluginRichEditor($this, opts); } }); }; })).apply(this, [window.themestrap, jQuery]);