UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

423 lines (378 loc) 11.8 kB
/** * This module allows to customize the built-in toolbar of the Rich Text Editor and use commands from the [HTML Editing APIs](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand). * It's highly recommended to keep this toolbar as small as possible, especially from styling commands (eg. 'fontSize') and leave this task to the Style Manager * * You can customize the initial state of the module from the editor initialization, by passing the following [Configuration Object](https://github.com/GrapesJS/grapesjs/blob/master/src/rich_text_editor/config/config.ts) * ```js * const editor = grapesjs.init({ * richTextEditor: { * // options * } * }) * ``` * * Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance. * * ```js * // Listen to events * editor.on('rte:enable', () => { ... }); * * // Use the API * const rte = editor.RichTextEditor; * rte.add(...); * ``` * * ## Available Events * * `rte:enable` - RTE enabled. The view, on which RTE is enabled, is passed as an argument * * `rte:disable` - RTE disabled. The view, on which RTE is disabled, is passed as an argument * * ## Methods * * [add](#add) * * [get](#get) * * [run](#run) * * [getAll](#getall) * * [remove](#remove) * * [getToolbarEl](#gettoolbarel) * * @module RichTextEditor */ import { debounce, isFunction, isString } from 'underscore'; import { Module } from '../abstract'; import { Debounced, Model } from '../common'; import ComponentView from '../dom_components/view/ComponentView'; import EditorModel from '../editor/model/Editor'; import { createEl, cx, on, removeEl } from '../utils/dom'; import { hasWin, isDef } from '../utils/mixins'; import defaults, { CustomRTE, RichTextEditorConfig } from './config/config'; import RichTextEditor, { RichTextEditorAction } from './model/RichTextEditor'; export type RichTextEditorEvent = 'rte:enable' | 'rte:disable' | 'rte:custom'; const eventsUp = 'change:canvasOffset frame:scroll component:update'; export const evEnable = 'rte:enable'; export const evDisable = 'rte:disable'; export const evCustom = 'rte:custom'; const events = { enable: evEnable, disable: evDisable, custom: evCustom, }; interface ModelRTE { currentView?: ComponentView; } export default class RichTextEditorModule extends Module<RichTextEditorConfig & { pStylePrefix?: string }> { pfx: string; toolbar!: HTMLElement; globalRte?: RichTextEditor; actionbar?: HTMLElement; lastEl?: HTMLElement; actions?: (RichTextEditorAction | string)[]; customRte?: CustomRTE; model: Model<ModelRTE>; __dbdTrgCustom: Debounced; events = events; /** * Get configuration object * @name getConfig * @function * @return {Object} */ constructor(em: EditorModel) { super(em, 'RichTextEditor', defaults); const { config } = this; const ppfx = config.pStylePrefix; if (ppfx) { config.stylePrefix = ppfx + config.stylePrefix; } this.pfx = config.stylePrefix!; this.actions = config.actions || []; const model = new Model(); this.model = model; model.on('change:currentView', this.__trgCustom, this); this.__dbdTrgCustom = debounce(() => this.__trgCustom(), 0); } onLoad() { if (!hasWin()) return; const { config } = this; const ppfx = config.pStylePrefix; const isCustom = config.custom; const toolbar = createEl('div', { class: cx(`${ppfx}rte-toolbar`, !isCustom && `${ppfx}one-bg ${ppfx}rte-toolbar-ui`), }); this.toolbar = toolbar; this.initRte(createEl('div')); //Avoid closing on toolbar clicking on(toolbar, 'mousedown', e => e.stopPropagation()); } __trgCustom() { const { model, em, events } = this; em.trigger(events.custom, { enabled: !!model.get('currentView'), container: this.getToolbarEl(), actions: this.getAll(), }); } destroy() { this.globalRte?.destroy(); this.customRte?.destroy?.(); this.model.stopListening().clear({ silent: true }); this.__dbdTrgCustom.cancel(); removeEl(this.toolbar); } /** * Post render callback * @param {View} ev * @private */ postRender(ev: any) { const canvas = ev.model.get('Canvas'); this.toolbar.style.pointerEvents = 'all'; this.hideToolbar(); canvas.getToolsEl().appendChild(this.toolbar); } /** * Init the built-in RTE * @param {HTMLElement} el * @return {RichTextEditor} * @private */ initRte(el: HTMLElement) { let { globalRte } = this; const { em, pfx, actionbar, config } = this; const actions = this.actions || [...config.actions!]; const classes = { actionbar: `${pfx}actionbar`, button: `${pfx}action`, active: `${pfx}active`, inactive: `${pfx}inactive`, disabled: `${pfx}disabled`, }; if (!globalRte) { globalRte = new RichTextEditor(em, el, { classes, actions, actionbar, actionbarContainer: this.toolbar, module: this, }); this.globalRte = globalRte; } else { globalRte.em = em; globalRte.setEl(el); } if (globalRte.actionbar) { this.actionbar = globalRte.actionbar; } if (globalRte.actions) { this.actions = globalRte.actions; } return globalRte; } /** * Add a new action to the built-in RTE toolbar * @param {string} name Action name * @param {Object} action Action options * @example * rte.add('bold', { * icon: '<b>B</b>', * attributes: {title: 'Bold'}, * result: rte => rte.exec('bold') * }); * rte.add('link', { * icon: document.getElementById('t'), * attributes: { title: 'Link' }, * // Example on how to wrap selected content * result: rte => rte.insertHTML(`<a href="#">${rte.selection()}</a>`) * }); * // An example with fontSize * rte.add('fontSize', { * icon: `<select class="gjs-field"> * <option>1</option> * <option>4</option> * <option>7</option> * </select>`, * // Bind the 'result' on 'change' listener * event: 'change', * result: (rte, action) => rte.exec('fontSize', action.btn.firstChild.value), * // Callback on any input change (mousedown, keydown, etc..) * update: (rte, action) => { * const value = rte.doc.queryCommandValue(action.name); * if (value != 'false') { // value is a string * action.btn.firstChild.value = value; * } * } * }) * // An example with state * const isValidAnchor = (rte) => { * // a utility function to help determine if the selected is a valid anchor node * const anchor = rte.selection().anchorNode; * const parentNode = anchor && anchor.parentNode; * const nextSibling = anchor && anchor.nextSibling; * return (parentNode && parentNode.nodeName == 'A') || (nextSibling && nextSibling.nodeName == 'A') * } * rte.add('toggleAnchor', { * icon: `<span style="transform:rotate(45deg)">&supdsub;</span>`, * state: (rte, doc) => { * if (rte && rte.selection()) { * // `btnState` is a integer, -1 for disabled, 0 for inactive, 1 for active * return isValidAnchor(rte) ? btnState.ACTIVE : btnState.INACTIVE; * } else { * return btnState.INACTIVE; * } * }, * result: (rte, action) => { * if (isValidAnchor(rte)) { * rte.exec('unlink'); * } else { * rte.insertHTML(`<a class="link" href="">${rte.selection()}</a>`); * } * } * }) */ add(name: string, action: Partial<RichTextEditorAction> = {}) { action.name = name; this.globalRte?.addAction(action as RichTextEditorAction, { sync: true }); } /** * Get the action by its name * @param {string} name Action name * @return {Object} * @example * const action = rte.get('bold'); * // {name: 'bold', ...} */ get(name: string): RichTextEditorAction | undefined { let result; this.globalRte?.getActions().forEach(action => { if (action.name == name) { result = action; } }); return result; } /** * Get all actions * @return {Array} */ getAll() { return this.globalRte?.getActions() || []; } /** * Remove the action from the toolbar * @param {string} name * @return {Object} Removed action * @example * const action = rte.remove('bold'); * // {name: 'bold', ...} */ remove(name: string) { const actions = this.getAll(); const action = this.get(name); if (action) { const btn = action.btn; const index = actions.indexOf(action); btn?.parentNode?.removeChild(btn); actions.splice(index, 1); } return action; } /** * Run action command. * @param action Action to run * @example * const action = rte.get('bold'); * rte.run(action) // or rte.run('bold') */ run(action: string | RichTextEditorAction) { const rte = this.globalRte; const actionRes = isString(action) ? this.get(action) : action; if (rte && actionRes) { actionRes.result(rte, actionRes); rte.updateActiveActions(); } } /** * Get the toolbar element * @return {HTMLElement} */ getToolbarEl() { return this.toolbar; } /** * Triggered when the offset of the editor is changed * @private */ updatePosition() { const { em, toolbar } = this; const un = 'px'; const canvas = em.Canvas; const { style } = toolbar; const pos = canvas.getTargetToElementFixed(this.lastEl!, toolbar, { event: 'rteToolbarPosUpdate', left: 0, }); ['top', 'left', 'bottom', 'right'].forEach(key => { const value = pos[key as keyof typeof pos]; if (isDef(value)) { style[key as any] = isString(value) ? value : (value || 0) + un; } }); } /** * Enable rich text editor on the element * @param {View} view Component view * @param {Object} rte The instance of already defined RTE * @private * */ async enable(view: ComponentView, rte: RichTextEditor, opts: any = {}) { this.lastEl = view.el; const { customRte, em } = this; const el = view.getChildrenContainer(); this.toolbar.style.display = ''; const rteInst = await (customRte ? customRte.enable(el, rte) : this.initRte(el).enable(opts)); if (em) { setTimeout(this.updatePosition.bind(this), 0); em.off(eventsUp, this.updatePosition, this); em.on(eventsUp, this.updatePosition, this); em.trigger('rte:enable', view, rteInst); } this.model.set({ currentView: view }); return rteInst; } async getContent(view: ComponentView, rte: RichTextEditor) { const { customRte } = this; if (customRte && rte && isFunction(customRte.getContent)) { return await customRte.getContent(view.el, rte); } else { return view.getChildrenContainer().innerHTML; } } hideToolbar() { const style = this.toolbar.style; const size = '-1000px'; style.top = size; style.left = size; style.display = 'none'; } /** * Unbind rich text editor from the element * @param {View} view * @param {Object} rte The instance of already defined RTE * @private * */ disable(view: ComponentView, rte?: RichTextEditor) { const { em } = this; const customRte = this.customRte; // @ts-ignore const el = view.getChildrenContainer(); if (customRte) { customRte.disable(el, rte); } else { rte && rte.disable(); } this.hideToolbar(); if (em) { em.off(eventsUp, this.updatePosition, this); em.trigger('rte:disable', view, rte); } this.model.unset('currentView'); } }