-
Notifications
You must be signed in to change notification settings - Fork 7
/
Copy pathSymbol.ts
455 lines (419 loc) · 16.6 KB
/
Symbol.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
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'
/**
* 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! 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
*/
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) {
s.addInstance(otherChild)
}
})
})
}
return s
}
export default Symbol