UNPKG

grapesjs-clot

Version:

Free and Open Source Web Builder Framework

362 lines (318 loc) 10.1 kB
// The initial version of this RTE was borrowed from https://github.com/jaredreich/pell // and adapted to the GrapesJS's need import { isString } from 'underscore'; import { on, off, getPointerEvent, getModel } from 'utils/mixins'; const RTE_KEY = '_rte'; const btnState = { ACTIVE: 1, INACTIVE: 0, DISABLED: -1, }; const isValidTag = (rte, tagName = 'A') => { const { anchorNode, focusNode } = rte.selection(); const parentAnchor = anchorNode?.parentNode; const parentFocus = focusNode?.parentNode; return parentAnchor?.nodeName == tagName || parentFocus?.nodeName == tagName; }; const customElAttr = 'data-selectme'; const defActions = { bold: { name: 'bold', icon: '<b>B</b>', attributes: { title: 'Bold' }, result: rte => rte.exec('bold'), }, italic: { name: 'italic', icon: '<i>I</i>', attributes: { title: 'Italic' }, result: rte => rte.exec('italic'), }, underline: { name: 'underline', icon: '<u>U</u>', attributes: { title: 'Underline' }, result: rte => rte.exec('underline'), }, strikethrough: { name: 'strikethrough', icon: '<s>S</s>', attributes: { title: 'Strike-through' }, result: rte => rte.exec('strikeThrough'), }, link: { icon: `<svg viewBox="0 0 24 24"> <path fill="currentColor" d="M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z" /> </svg>`, name: 'link', attributes: { style: 'font-size:1.4rem;padding:0 4px 2px;', title: 'Link', }, state: rte => { return rte && rte.selection() && isValidTag(rte) ? btnState.ACTIVE : btnState.INACTIVE; }, result: rte => { if (isValidTag(rte)) { rte.exec('unlink'); } else { rte.insertHTML(`<a href="" ${customElAttr}>${rte.selection()}</a>`, { select: true }); } }, }, wrap: { icon: `<svg viewBox="0 0 24 24"> <path fill="currentColor" d="M20.71,4.63L19.37,3.29C19,2.9 18.35,2.9 17.96,3.29L9,12.25L11.75,15L20.71,6.04C21.1,5.65 21.1,5 20.71,4.63M7,14A3,3 0 0,0 4,17C4,18.31 2.84,19 2,19C2.92,20.22 4.5,21 6,21A4,4 0 0,0 10,17A3,3 0 0,0 7,14Z" /> </svg>`, attributes: { title: 'Wrap for style' }, state: rte => { return rte?.selection() && isValidTag(rte, 'SPAN') ? btnState.DISABLED : btnState.INACTIVE; }, result: rte => { !isValidTag(rte, 'SPAN') && rte.insertHTML(`<span ${customElAttr}>${rte.selection()}</span>`, { select: true }); }, }, }; export default class RichTextEditor { constructor(settings = {}) { const { el, em } = settings; this.em = em; if (el[RTE_KEY]) { return el[RTE_KEY]; } el[RTE_KEY] = this; this.setEl(el); this.updateActiveActions = this.updateActiveActions.bind(this); this.__onKeydown = this.__onKeydown.bind(this); const acts = (settings.actions || []).map(action => { let result = action; if (typeof action === 'string') { result = { ...defActions[action] }; } else if (defActions[action.name]) { result = { ...defActions[action.name], ...action }; } return result; }); const actions = acts.length ? acts : Object.keys(defActions).map(a => defActions[a]); settings.classes = { ...{ actionbar: 'actionbar', button: 'action', active: 'active', disabled: 'disabled', inactive: 'inactive', }, ...settings.classes, }; const classes = settings.classes; let actionbar = settings.actionbar; this.actionbar = actionbar; this.settings = settings; this.classes = classes; this.actions = actions; if (!actionbar) { const actionbarCont = settings.actionbarContainer; actionbar = document.createElement('div'); actionbar.className = classes.actionbar; actionbarCont.appendChild(actionbar); this.actionbar = actionbar; actions.forEach(action => this.addAction(action)); } settings.styleWithCSS && this.exec('styleWithCSS'); return this; } destroy() { this.el = 0; this.doc = 0; this.actionbar = 0; this.settings = {}; this.classes = {}; this.actions = []; } setEl(el) { this.el = el; this.doc = el.ownerDocument; } updateActiveActions() { this.getActions().forEach(action => { const btn = action.btn; const update = action.update; const { active, inactive, disabled } = { ...this.classes }; const state = action.state; const name = action.name; const doc = this.doc; btn.className = btn.className.replace(active, '').trim(); btn.className = btn.className.replace(inactive, '').trim(); btn.className = btn.className.replace(disabled, '').trim(); // if there is a state function, which depicts the state, // i.e. `active`, `disabled`, then call it if (state) { switch (state(this, doc)) { case btnState.ACTIVE: btn.className += ` ${active}`; break; case btnState.INACTIVE: btn.className += ` ${inactive}`; break; case btnState.DISABLED: btn.className += ` ${disabled}`; break; } } else { // otherwise default to checking if the name command is supported & enabled if (doc.queryCommandSupported(name) && doc.queryCommandState(name)) { btn.className += ` ${active}`; } } update && update(this, action); }); } enable(opts) { if (this.enabled) return this; return this.__toggleEffects(true, opts); } disable() { return this.__toggleEffects(false); } __toggleEffects(enable = false, opts = {}) { const method = enable ? on : off; const { el, doc } = this; this.actionbarEl().style.display = enable ? '' : 'none'; el.contentEditable = !!enable; method(el, 'mouseup keyup', this.updateActiveActions); method(doc, 'keydown', this.__onKeydown); this.enabled = enable; if (enable) { const { event } = opts; this.syncActions(); this.updateActiveActions(); if (event) { let range = null; if (doc.caretRangeFromPoint) { const poiner = getPointerEvent(event); range = doc.caretRangeFromPoint(poiner.clientX, poiner.clientY); } else if (event.rangeParent) { range = doc.createRange(); range.setStart(event.rangeParent, event.rangeOffset); } const sel = doc.getSelection(); sel.removeAllRanges(); range && sel.addRange(range); } el.focus(); } return this; } __onKeydown(event) { if (event.key === 'Enter') { this.doc.execCommand('insertLineBreak'); event.preventDefault(); } } /** * Sync actions with the current RTE */ syncActions() { this.getActions().forEach(action => { if (this.actionbar) { if (!action.state || (action.state && action.state(this, this.doc) >= 0)) { const event = action.event || 'click'; action.btn[`on${event}`] = e => { action.result(this, action); this.updateActiveActions(); }; } } }); } /** * Add new action to the actionbar * @param {Object} action * @param {Object} [opts={}] */ addAction(action, opts = {}) { const sync = opts.sync; const btn = document.createElement('span'); const icon = action.icon; const attr = action.attributes || {}; btn.className = this.classes.button; action.btn = btn; for (let key in attr) { btn.setAttribute(key, attr[key]); } if (typeof icon == 'string') { btn.innerHTML = icon; } else { btn.appendChild(icon); } this.actionbarEl().appendChild(btn); if (sync) { this.actions.push(action); this.syncActions(); } } /** * Get the array of current actions * @return {Array} */ getActions() { return this.actions; } /** * Returns the Selection instance * @return {Selection} */ selection() { return this.doc.getSelection(); } /** * Execute the command * @param {string} command Command name * @param {any} [value=null Command's arguments */ exec(command, value = null) { this.doc.execCommand(command, false, value); } /** * Get the actionbar element * @return {HTMLElement} */ actionbarEl() { return this.actionbar; } /** * Set custom HTML to the selection, useful as the default 'insertHTML' command * doesn't work in the same way on all browsers * @param {string} value HTML string */ insertHTML(value, { select } = {}) { const { em, doc, el } = this; const sel = doc.getSelection(); if (sel && sel.rangeCount) { const model = getModel(el); const node = doc.createElement('div'); const range = sel.getRangeAt(0); range.deleteContents(); if (isString(value)) { node.innerHTML = value; } else if (value) { node.appendChild(value); } Array.prototype.slice.call(node.childNodes).forEach(nd => { range.insertNode(nd); }); sel.removeAllRanges(); sel.addRange(range); el.focus(); if (select && model) { model.once('rte:disable', () => { const toSel = model.find(`[${customElAttr}]`)[0]; if (!toSel) return; em.setSelected(toSel); toSel.removeAttributes(customElAttr); }); model.trigger('disable'); } } } }