UNPKG

@silexlabs/grapesjs-symbols

Version:
468 lines (430 loc) 17.3 kB
import Backbone from 'backbone' import { Component, ComponentProperties } from 'grapesjs' import { SymbolEditor, Symbols } from './Symbols' 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' export interface SymbolOptions { appendTo?: string emptyText?: string primaryColor?: string secondaryColor?: string highlightColor?: string } /** * 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` * */ override 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! as any as Symbols).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') as Component 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 */ override 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: Component | null = null, excludeOne: Component | null = null) { const values = Array.from((this.get('instances') as Map<string, Component>).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: Component, srcChildren: Component[], cbk: (dstChildren: Component[], dstInst: Component) => void ) { this.getAll(this.get('model') as Component, 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: Component, srcChild: Component) { 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: Component, symbolChildId: string) { // 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: Component, parent: Component, srcChild: Component) { 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: Component, srcChild: Component) { 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: Component, srcChild: Component) { 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: Component, srcChild: Component, changed: object, removed: string[]) { 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: Component) { 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: Component) { return !!c.get(SYMBOL_ID_ATTRIBUTE) && this.get('instances')!.has(c.cid) } /** * unlink all instances of a symbol */ unlinkAll() { this.get('instances')!.forEach((c: Component) => 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: Component) { 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: Component): string { return c.get(SYMBOL_ID_ATTRIBUTE) } /** * remove symbols IDs from an instance */ export function cleanup(c: Component) { 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: Component, { icon, label, symbolId }: ComponentProperties) { // 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 as any).isSymbol)) { toolbar.push({ attributes: { class: 'fa fa-ban on fa-diamond', title: label, }, command: 'do:nothing', isSymbol: true, // prevent add 2 buttons } as any) } // 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: Component, force: boolean = 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: SymbolEditor, c: Component, attributes: ComponentProperties): Symbol { const symbolId = attributes.symbolId ?? `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) as Symbol // 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 => { // For each instance of s and its children const otherChild = find(otherInst, child.get(SYMBOL_CHILD_ID_ATTRIBUTE)) console.log('otherChild', otherChild?.view?.el, otherChild?.get(SYMBOL_ID_ATTRIBUTE), otherChild?.get(SYMBOL_CHILD_ID_ATTRIBUTE)) otherChild?.set(SYMBOL_ID_ATTRIBUTE, symbolId) 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