UNPKG

@silexlabs/grapesjs-symbols

Version:
443 lines 18.8 kB
import Backbone from 'backbone'; import { find, all, children, getCaret, setCaret, closestInstance } from '../utils'; import { uniqueId } from 'underscore'; // type SymbolAttributes = { // id: string, // model: Component, // label?: string, // icon?: string, // instances?: Map<string, Component>, // } export const SYMBOL_ID_ATTRIBUTE = 'symbolId'; export const SYMBOL_CHILD_ID_ATTRIBUTE = 'symbolChildId'; export const SYMBOL_SYNC_ATTRIBUTE = 'symbolSync'; /** * A Symbol class holds the data about a symbol: label, icon * The `model` attribute is a grapesjs Component used to create new instances * The `instances` attribute is a Map of grapesjs Components kept in sync with the model * The model is kept up to date by calling the apply* methods * * @member {string} attributes.label * @member {string} attributes.icon * @member {Component} attributes.model * @member {Map.<string, Component>} attributes.instances * * @class */ class Symbol extends Backbone.Model { /** * Default options passed to the constructor */ //defaults: { // label: 'New Symbol', // icon: 'fa-question', //}, /** * @param {{ label: ?string, icon: ?string }} attributes * @param {Object} model - to be converted to Component and stored in attributes.model * Notes: * - `attributes.instances` will initially be empty until addInstance is called by the Symbols class (onAdd method) * - `attributes.model` may initially be a Component (creation of a Symbol) or JSON data (loaded symbol from storage). It is always converted to a Component in `initialize` * */ initialize() { // Check required attributes if (!this.has('model')) throw new Error('Could not create Symbol: model is required'); // Init the required instances on the symbol if (!this.has('instances')) { this.set('instances', new Map()); } if (this.collection) { // This is false during unit tests // Get a ref to grapesjs editor const editor = this.collection.editor; // `attributes.model` may initially be a Component (creation of a Symbol) or JSON data (loaded symbol from storage). It is always converted to a Component in `initialize` // in which case we convert model to a real component // TODO: Needs review const model = this.get('model'); if (!model.cid) { // FIXME: should be typeof model = 'string' const [modelComp] = editor.addComponents([model]); this.set('model', modelComp); } // Make sure the symbol instances are undoable editor.UndoManager.add(this); } } /** * Return a shallow copy of the model's attributes for JSON * stringification. * @return {Object} * @private */ toJSON(opts = {}) { const obj = Backbone.Model.prototype.toJSON.call(this, opts); delete obj.instances; return obj; } /** * Get all instances as an Array, except the `excludeOne` one * @param callback which receives the instances * @param excludeOne - optionally exclude one component * @param addOne - optionally add one component, typically pass the symbol's `model` attribute when needed * @returns The symbol instances * @private */ getAll(addOne = null, excludeOne = null) { const values = Array.from(this.get('instances').values()); return (addOne ? [addOne] : []).concat(excludeOne ? values.filter(inst => inst.cid != excludeOne.cid) : values); } /** * Browse all instances and their children matching the changed component * Includes the `model` of this symbol * Will not include the provided instance `srcInst` nor `srcChild` as they are the ones which changed * @param srcInst - the instance of this symbol containing `child` * @param srcChild - the child which has the changes */ browseInstancesAndModel(srcInst, srcChildren, cbk) { this.getAll(this.get('model'), srcInst) .forEach(dstInst => { const dstChildren = srcChildren .map(srcChild => { // Get a child or the root return srcChild.has(SYMBOL_ID_ATTRIBUTE) ? dstInst // this is the root : find(dstInst, srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)); // this is a child }); cbk(dstChildren, dstInst); }); } /** * Apply css classes to all instances and their children according to changes of a component * Also update the `model` attribute of this symbol * @param srcInst - the instance of this symbol containing `child` * @param srcChild - the child which has the changes */ applyClasses(srcInst, srcChild) { if (srcInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; this.browseInstancesAndModel(srcInst, [srcChild], ([dstChild], dstInst) => { if (dstInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; if (dstChild) { dstChild.setClass(srcChild.getClasses()); } else { console.error(`Could not sync classes for symbol ${this.cid}: ${srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)} not found in ${dstInst.cid}`); } }); } getIndex(parent, symbolChildId) { // TODO: Needs review return parent.components().toArray() .findIndex(c => c.get(SYMBOL_CHILD_ID_ATTRIBUTE) === symbolChildId); } /** * Update attributes of all instances and their children according to changes of a component * Also update the `model` attribute of this symbol * @param srcInst - the instance of this symbol containing `child` * @param parent - the element whose children have changed * @param srcChild - the child which has the changes */ applyChildren(srcInst, parent, srcChild) { if (srcInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; if (!parent) throw new Error(`Could not sync children for symbol ${this.cid}: ${srcChild.cid} has no parent`); // Get all instances of this symbol const allInst = all(srcInst) .filter(inst => inst.get(SYMBOL_SYNC_ATTRIBUTE) !== false); // Handle the create/update/remove cases if (allInst.includes(srcChild)) { // The child is in the instance const symbolChildId = srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE); // Case of a child being duplicated inside the symbol const isDuplicate = !!symbolChildId && allInst .filter(c => c.get(SYMBOL_CHILD_ID_ATTRIBUTE) === symbolChildId && c.parent() === parent).length > 1; if (symbolChildId && !isDuplicate) { // Case of a moving child inside the instance this.browseInstancesAndModel(srcInst, [srcChild, parent], ([dstChild, dstParent], dstInst) => { if (dstChild && dstParent) { dstParent.append(dstChild, { at: srcChild.index() }); } else { console.error(`Could not sync child for symbol ${this.cid}: ${srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)} not found in ${dstInst.cid}`, { dstChild, dstParent }); } }); } else { // this is a new child all(srcChild) // Force new symbolChildId for `srcChild` and its children (excluding symbols) // because `c` might be a duplicate of another child // this happens when we duplicate a component inside a symbol .forEach(c => initSymbolChild(c, true)); this.browseInstancesAndModel(srcInst, [parent], ([dstParent], dstInst) => { if (dstParent) { const clone = srcChild.clone(); dstParent.append(clone, { at: srcChild.index() }); } else { console.error(`Could not sync attributes for symbol ${this.cid}: ${srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)} not found in ${dstInst.cid}`); } }); } } else { // Child is not there anymore // Beware: srcChild has no parent nor view anymore this.browseInstancesAndModel(srcInst, [srcChild], ([dstChild], dstInst) => { if (dstChild) { dstChild.remove(); } else { console.error(`Could not sync attributes for symbol ${this.cid}: ${srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)} not found in ${dstInst.cid}`); } }); // this child is not part of a symbol anymore srcChild.set(SYMBOL_CHILD_ID_ATTRIBUTE); } } /** * Update attributes of all instances and their children according to changes of a component * Also update the `model` attribute of this symbol * @param srcInst - the instance of this symbol containing `child` * @param srcChild - the child which has the changes */ applyAttributes(srcInst, srcChild) { if (srcInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; this.browseInstancesAndModel(srcInst, [srcChild], ([dstChild], dstInst) => { if (dstInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; if (dstChild) { // doesnt work: dstChild.setAttributes(srcChild.getAttributes()) dstChild.attributes = srcChild.attributes; } else { console.error(`Could not sync attributes for symbol ${this.cid}: ${srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)} not found in ${dstInst.cid}`); } }); } /** * Update text content of all instances and their children according to changes of a component * Also update the `model` attribute of this symbol * Will not update the provided instance `inst` as it is the one which changed * @param srcInst - the instance of this symbol containing `child` * @param srcChild - the child which has the changes */ applyContent(srcInst, srcChild) { if (srcInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; // Store the caret position in the contenteditable container const el = srcChild.getCurrentView().el; const caret = getCaret(el); this.browseInstancesAndModel(srcInst, [srcChild], ([dstChild], dstInst) => { if (dstInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; if (dstChild) { if (dstChild.get('type') === 'text') { // FIXME: sometimes type is "" // Sets the new content dstChild.components(srcChild.getCurrentView().el.innerHTML); } else { console.error('applyContent, NOT A TEXT', dstChild, dstChild.get('type')); } } else { console.error(`Could not sync content for symbol ${this.cid}: ${srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)} not found in ${dstInst.cid}`); } }); // Restore the caret position in the contenteditable container // TODO: need review // FIXME: Why is the caret reset after we change the components which do not have the focus? setTimeout(() => { // After dom update setCaret(el, caret); }); } /** * Update styles of all instances and their children according to changes of a component * Also update the `model` attribute of this symbol * Will not update the provided instance `inst` as it is the one which changed * @param srcInst - the instance of this symbol containing `child` * @param srcChild - the child which has the changes * @param changed - the changed styles * @param removed - the removed styles */ applyStyle(srcInst, srcChild, changed, removed) { if (srcInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; this.browseInstancesAndModel(srcInst, [srcChild], ([dstChild], dstInst) => { if (dstInst.get(SYMBOL_SYNC_ATTRIBUTE) === false) return; if (dstChild) { dstChild.setStyle({ ...dstChild.getStyle(), ...changed, }); removed.forEach(styleName => dstChild.removeStyle(styleName)); } else { console.error(`Could not sync content for symbol ${this.cid}: ${srcChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)} not found in ${dstInst.cid}`); } }); } /** * Add a component to this symbol `instances` * Called at init when editor triggers a 'components:add' event * @param c */ addInstance(c) { this.get('instances').set(c.cid, c); // here we use cid as `instances` is built and not saved } /** * @return {Component} The created instance, ready to be added to a component */ createInstance() { // Clone the model const inst = this.get('model').clone(); // Add the component to the symbol instances this.addInstance(inst); // Let the caller add it to a component return inst; } /** * @param c - a component * @return {Boolean} true if the component is a symbol */ isInstance(c) { return !!c.get(SYMBOL_ID_ATTRIBUTE) && this.get('instances').has(c.cid); } /** * unlink all instances of a symbol */ unlinkAll() { this.get('instances').forEach((c) => this.unlink(c)); } /** * unlink an instance from a symbol * - remove the symbol ID from the attributes * - remove `symbolChildId` from all the children until they are symbols * - remove the reference in instances */ unlink(c) { c.set(SYMBOL_ID_ATTRIBUTE); this.get('instances').delete(c.cid); children(c) .forEach(child => child.set(SYMBOL_CHILD_ID_ATTRIBUTE)); } } /** * @param c - a component, supposedly an instance of a symbol * @return the symbol ID if the component is a symbol */ export function getSymbolId(c) { return c.get(SYMBOL_ID_ATTRIBUTE); } /** * remove symbols IDs from an instance */ export function cleanup(c) { c.set(SYMBOL_ID_ATTRIBUTE); c.set(SYMBOL_CHILD_ID_ATTRIBUTE); } /** * Init a component to be this symbol's `model` * Also init the component's children * @param {Component} c */ export function initModel(c, { icon, label, symbolId }) { // check that it is not part of a Symbol already if (c.has(SYMBOL_ID_ATTRIBUTE)) { throw new Error('Could not init Symbol model: the model has already been init'); } // This is the symbol cid c.set(SYMBOL_ID_ATTRIBUTE, symbolId); // add symbol data c.set('icon', `<span class="fa ${icon}"></span>`); // Show that this is a symbol, add an icon to the toolbar UI const toolbar = c.get('toolbar'); // FIXME: somehow this happens twice => we should not have to do this check if (!toolbar.find(t => !!t.isSymbol)) { toolbar.push({ attributes: { class: 'fa fa-ban on fa-diamond', title: label, }, command: 'do:nothing', isSymbol: true, // prevent add 2 buttons }); } // init children children(c) .forEach(child => initSymbolChild(child)); } /** * Init a component to be this symbol's `model`'s child * @param {Component} c */ export function initSymbolChild(c, force = false) { if (force || !c.has(SYMBOL_CHILD_ID_ATTRIBUTE)) { c.set(SYMBOL_CHILD_ID_ATTRIBUTE, c.cid); } } /** * create a new symbol ou of a component * the component and its children will be init * the component will be cloned and stored as the model * @return {Symbol} */ export function createSymbol(editor, c, attributes) { var _a; const symbolId = (_a = attributes.symbolId) !== null && _a !== void 0 ? _a : `s_${uniqueId()}_${new Date().getTime()}`; // If the component is in a symbol, we need to update all instances const inst = closestInstance(c); // Init component with symbolId and children initModel(c, { ...attributes, symbolId, }); // Create a Symbol const s = new Symbol({ ...attributes, id: symbolId, // Clone the component, store a model model: c.clone(), }); // Store a ref s.addInstance(c); // Handle the case where the new symbol is a child of another symbol if (inst) { // For all instances containing c, make c an instance of the new symbolId const parentSymbolId = getSymbolId(inst); const parentSymbol = editor.Symbols.get(parentSymbolId); // For each child of the new symbol all(c) // For each instance of the parent symbol (containing a soon to be instance of s) .forEach(child => { // Here child is a component of the new symbol parentSymbol.getAll(null, inst) .forEach(otherInst => { var _a; // For each instance of s and its children const otherChild = find(otherInst, child.get(SYMBOL_CHILD_ID_ATTRIBUTE)); console.log('otherChild', (_a = otherChild === null || otherChild === void 0 ? void 0 : otherChild.view) === null || _a === void 0 ? void 0 : _a.el, otherChild === null || otherChild === void 0 ? void 0 : otherChild.get(SYMBOL_ID_ATTRIBUTE), otherChild === null || otherChild === void 0 ? void 0 : otherChild.get(SYMBOL_CHILD_ID_ATTRIBUTE)); otherChild === null || otherChild === void 0 ? void 0 : otherChild.set(SYMBOL_ID_ATTRIBUTE, symbolId); otherChild === null || otherChild === void 0 ? void 0 : otherChild.set(SYMBOL_CHILD_ID_ATTRIBUTE, child.get(SYMBOL_CHILD_ID_ATTRIBUTE)); // Add the new instance to the symbol if (child === c) { if (otherChild) { s.addInstance(otherChild); } else { console.error('Could not find child', child.get(SYMBOL_CHILD_ID_ATTRIBUTE)); } } }); }); } return s; } export default Symbol; //# sourceMappingURL=Symbol.js.map