@silexlabs/grapesjs-symbols
Version:
Symbols for GrapesJS
443 lines • 18.8 kB
JavaScript
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