@silexlabs/grapesjs-symbols
Version:
Symbols for GrapesJS
468 lines (430 loc) • 17.3 kB
text/typescript
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