@svgedit/svgcanvas 7.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/history.js ADDED
@@ -0,0 +1,619 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * For command history tracking and undo functionality.
4
+ * @module history
5
+ * @license MIT
6
+ * @copyright 2010 Jeff Schiller
7
+ */
8
+
9
+ import { getHref, setHref, getRotationAngle, getBBox } from './utilities.js'
10
+
11
+ /**
12
+ * Group: Undo/Redo history management.
13
+ */
14
+ export const HistoryEventTypes = {
15
+ BEFORE_APPLY: 'before_apply',
16
+ AFTER_APPLY: 'after_apply',
17
+ BEFORE_UNAPPLY: 'before_unapply',
18
+ AFTER_UNAPPLY: 'after_unapply'
19
+ }
20
+
21
+ /**
22
+ * Base class for commands.
23
+ */
24
+ export class Command {
25
+ /**
26
+ * @returns {string}
27
+ */
28
+ getText () {
29
+ return this.text
30
+ }
31
+
32
+ /**
33
+ * @param {module:history.HistoryEventHandler} handler
34
+ * @param {callback} applyFunction
35
+ * @returns {void}
36
+ */
37
+ apply (handler, applyFunction) {
38
+ handler && handler.handleHistoryEvent(HistoryEventTypes.BEFORE_APPLY, this)
39
+ applyFunction(handler)
40
+ handler && handler.handleHistoryEvent(HistoryEventTypes.AFTER_APPLY, this)
41
+ }
42
+
43
+ /**
44
+ * @param {module:history.HistoryEventHandler} handler
45
+ * @param {callback} unapplyFunction
46
+ * @returns {void}
47
+ */
48
+ unapply (handler, unapplyFunction) {
49
+ handler && handler.handleHistoryEvent(HistoryEventTypes.BEFORE_UNAPPLY, this)
50
+ unapplyFunction()
51
+ handler && handler.handleHistoryEvent(HistoryEventTypes.AFTER_UNAPPLY, this)
52
+ }
53
+
54
+ /**
55
+ * @returns {Element[]} Array with element associated with this command
56
+ * This function needs to be surcharged if multiple elements are returned.
57
+ */
58
+ elements () {
59
+ return [this.elem]
60
+ }
61
+
62
+ /**
63
+ * @returns {string} String with element associated with this command
64
+ */
65
+ type () {
66
+ return this.constructor.name
67
+ }
68
+ }
69
+
70
+ // Todo: Figure out why the interface members aren't showing
71
+ // up (with or without modules applied), despite our apparently following
72
+ // http://usejsdoc.org/tags-interface.html#virtual-comments
73
+
74
+ /**
75
+ * An interface that all command objects must implement.
76
+ * @interface module:history.HistoryCommand
77
+ */
78
+ /**
79
+ * Applies.
80
+ *
81
+ * @function module:history.HistoryCommand#apply
82
+ * @param {module:history.HistoryEventHandler} handler
83
+ * @fires module:history~Command#event:history
84
+ * @returns {void|true}
85
+ */
86
+ /**
87
+ *
88
+ * Unapplies.
89
+ * @function module:history.HistoryCommand#unapply
90
+ * @param {module:history.HistoryEventHandler} handler
91
+ * @fires module:history~Command#event:history
92
+ * @returns {void|true}
93
+ */
94
+ /**
95
+ * Returns the elements.
96
+ * @function module:history.HistoryCommand#elements
97
+ * @returns {Element[]}
98
+ */
99
+ /**
100
+ * Gets the text.
101
+ * @function module:history.HistoryCommand#getText
102
+ * @returns {string}
103
+ */
104
+ /**
105
+ * Gives the type.
106
+ * @function module:history.HistoryCommand.type
107
+ * @returns {string}
108
+ */
109
+
110
+ /**
111
+ * @event module:history~Command#event:history
112
+ * @type {module:history.HistoryCommand}
113
+ */
114
+
115
+ /**
116
+ * An interface for objects that will handle history events.
117
+ * @interface module:history.HistoryEventHandler
118
+ */
119
+ /**
120
+ *
121
+ * @function module:history.HistoryEventHandler#handleHistoryEvent
122
+ * @param {string} eventType One of the HistoryEvent types
123
+ * @param {module:history~Command#event:history} command
124
+ * @listens module:history~Command#event:history
125
+ * @returns {void}
126
+ *
127
+ */
128
+
129
+ /**
130
+ * History command for an element that had its DOM position changed.
131
+ * @implements {module:history.HistoryCommand}
132
+ */
133
+ export class MoveElementCommand extends Command {
134
+ /**
135
+ * @param {Element} elem - The DOM element that was moved
136
+ * @param {Element} oldNextSibling - The element's next sibling before it was moved
137
+ * @param {Element} oldParent - The element's parent before it was moved
138
+ * @param {string} [text] - An optional string visible to user related to this change
139
+ */
140
+ constructor (elem, oldNextSibling, oldParent, text) {
141
+ super()
142
+ this.elem = elem
143
+ this.text = text ? ('Move ' + elem.tagName + ' to ' + text) : ('Move ' + elem.tagName)
144
+ this.oldNextSibling = oldNextSibling
145
+ this.oldParent = oldParent
146
+ this.newNextSibling = elem.nextSibling
147
+ this.newParent = elem.parentNode
148
+ }
149
+
150
+ /**
151
+ * Re-positions the element.
152
+ * @param {module:history.HistoryEventHandler} handler
153
+ * @fires module:history~Command#event:history
154
+ * @returns {void}
155
+ */
156
+ apply (handler) {
157
+ super.apply(handler, () => {
158
+ this.elem = this.newParent.insertBefore(this.elem, this.newNextSibling)
159
+ })
160
+ }
161
+
162
+ /**
163
+ * Positions the element back to its original location.
164
+ * @param {module:history.HistoryEventHandler} handler
165
+ * @fires module:history~Command#event:history
166
+ * @returns {void}
167
+ */
168
+ unapply (handler) {
169
+ super.unapply(handler, () => {
170
+ this.elem = this.oldParent.insertBefore(this.elem, this.oldNextSibling)
171
+ })
172
+ }
173
+ }
174
+
175
+ /**
176
+ * History command for an element that was added to the DOM.
177
+ * @implements {module:history.HistoryCommand}
178
+ */
179
+ export class InsertElementCommand extends Command {
180
+ /**
181
+ * @param {Element} elem - The newly added DOM element
182
+ * @param {string} text - An optional string visible to user related to this change
183
+ */
184
+ constructor (elem, text) {
185
+ super()
186
+ this.elem = elem
187
+ this.text = text || ('Create ' + elem.tagName)
188
+ this.parent = elem.parentNode
189
+ this.nextSibling = this.elem.nextSibling
190
+ }
191
+
192
+ /**
193
+ * Re-inserts the new element.
194
+ * @param {module:history.HistoryEventHandler} handler
195
+ * @fires module:history~Command#event:history
196
+ * @returns {void}
197
+ */
198
+ apply (handler) {
199
+ super.apply(handler, () => {
200
+ this.elem = this.parent.insertBefore(this.elem, this.nextSibling)
201
+ })
202
+ }
203
+
204
+ /**
205
+ * Removes the element.
206
+ * @param {module:history.HistoryEventHandler} handler
207
+ * @fires module:history~Command#event:history
208
+ * @returns {void}
209
+ */
210
+ unapply (handler) {
211
+ super.unapply(handler, () => {
212
+ this.parent = this.elem.parentNode
213
+ this.elem.remove()
214
+ })
215
+ }
216
+ }
217
+
218
+ /**
219
+ * History command for an element removed from the DOM.
220
+ * @implements {module:history.HistoryCommand}
221
+ */
222
+ export class RemoveElementCommand extends Command {
223
+ /**
224
+ * @param {Element} elem - The removed DOM element
225
+ * @param {Node} oldNextSibling - The DOM element's nextSibling when it was in the DOM
226
+ * @param {Element} oldParent - The DOM element's parent
227
+ * @param {string} [text] - An optional string visible to user related to this change
228
+ */
229
+ constructor (elem, oldNextSibling, oldParent, text) {
230
+ super()
231
+ this.elem = elem
232
+ this.text = text || ('Delete ' + elem.tagName)
233
+ this.nextSibling = oldNextSibling
234
+ this.parent = oldParent
235
+ }
236
+
237
+ /**
238
+ * Re-removes the new element.
239
+ * @param {module:history.HistoryEventHandler} handler
240
+ * @fires module:history~Command#event:history
241
+ * @returns {void}
242
+ */
243
+ apply (handler) {
244
+ super.apply(handler, () => {
245
+ this.parent = this.elem.parentNode
246
+ this.elem.remove()
247
+ })
248
+ }
249
+
250
+ /**
251
+ * Re-adds the new element.
252
+ * @param {module:history.HistoryEventHandler} handler
253
+ * @fires module:history~Command#event:history
254
+ * @returns {void}
255
+ */
256
+ unapply (handler) {
257
+ super.unapply(handler, () => {
258
+ if (!this.nextSibling) {
259
+ console.error('Reference element was lost')
260
+ }
261
+ this.parent.insertBefore(this.elem, this.nextSibling) // Don't use `before` or `prepend` as `this.nextSibling` may be `null`
262
+ })
263
+ }
264
+ }
265
+
266
+ /**
267
+ * @typedef {"#text"|"#href"|string} module:history.CommandAttributeName
268
+ */
269
+ /**
270
+ * @typedef {PlainObject<module:history.CommandAttributeName, string>} module:history.CommandAttributes
271
+ */
272
+
273
+ /**
274
+ * History command to make a change to an element.
275
+ * Usually an attribute change, but can also be textcontent.
276
+ * @implements {module:history.HistoryCommand}
277
+ */
278
+ export class ChangeElementCommand extends Command {
279
+ /**
280
+ * @param {Element} elem - The DOM element that was changed
281
+ * @param {module:history.CommandAttributes} attrs - Attributes to be changed with the values they had *before* the change
282
+ * @param {string} text - An optional string visible to user related to this change
283
+ */
284
+ constructor (elem, attrs, text) {
285
+ super()
286
+ this.elem = elem
287
+ this.text = text ? ('Change ' + elem.tagName + ' ' + text) : ('Change ' + elem.tagName)
288
+ this.newValues = {}
289
+ this.oldValues = attrs
290
+ for (const attr in attrs) {
291
+ if (attr === '#text') {
292
+ this.newValues[attr] = (elem) ? elem.textContent : ''
293
+ } else if (attr === '#href') {
294
+ this.newValues[attr] = getHref(elem)
295
+ } else {
296
+ this.newValues[attr] = elem.getAttribute(attr)
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Performs the stored change action.
303
+ * @param {module:history.HistoryEventHandler} handler
304
+ * @fires module:history~Command#event:history
305
+ * @returns {void}
306
+ */
307
+ apply (handler) {
308
+ super.apply(handler, () => {
309
+ let bChangedTransform = false
310
+ Object.entries(this.newValues).forEach(([attr, value]) => {
311
+ if (value) {
312
+ if (attr === '#text') {
313
+ this.elem.textContent = value
314
+ } else if (attr === '#href') {
315
+ setHref(this.elem, value)
316
+ } else {
317
+ this.elem.setAttribute(attr, value)
318
+ }
319
+ } else if (attr === '#text') {
320
+ this.elem.textContent = ''
321
+ } else {
322
+ this.elem.setAttribute(attr, '')
323
+ this.elem.removeAttribute(attr)
324
+ }
325
+
326
+ if (attr === 'transform') { bChangedTransform = true }
327
+ })
328
+
329
+ // relocate rotational transform, if necessary
330
+ if (!bChangedTransform) {
331
+ const angle = getRotationAngle(this.elem)
332
+ if (angle) {
333
+ const bbox = getBBox(this.elem)
334
+ const cx = bbox.x + bbox.width / 2
335
+ const cy = bbox.y + bbox.height / 2
336
+ const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
337
+ if (rotate !== this.elem.getAttribute('transform')) {
338
+ this.elem.setAttribute('transform', rotate)
339
+ }
340
+ }
341
+ }
342
+ })
343
+ }
344
+
345
+ /**
346
+ * Reverses the stored change action.
347
+ * @param {module:history.HistoryEventHandler} handler
348
+ * @fires module:history~Command#event:history
349
+ * @returns {void}
350
+ */
351
+ unapply (handler) {
352
+ super.unapply(handler, () => {
353
+ let bChangedTransform = false
354
+ Object.entries(this.oldValues).forEach(([attr, value]) => {
355
+ if (value) {
356
+ if (attr === '#text') {
357
+ this.elem.textContent = value
358
+ } else if (attr === '#href') {
359
+ setHref(this.elem, value)
360
+ } else {
361
+ this.elem.setAttribute(attr, value)
362
+ }
363
+ } else if (attr === '#text') {
364
+ this.elem.textContent = ''
365
+ } else {
366
+ this.elem.removeAttribute(attr)
367
+ }
368
+ if (attr === 'transform') { bChangedTransform = true }
369
+ })
370
+ // relocate rotational transform, if necessary
371
+ if (!bChangedTransform) {
372
+ const angle = getRotationAngle(this.elem)
373
+ if (angle) {
374
+ const bbox = getBBox(this.elem)
375
+ const cx = bbox.x + bbox.width / 2
376
+ const cy = bbox.y + bbox.height / 2
377
+ const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
378
+ if (rotate !== this.elem.getAttribute('transform')) {
379
+ this.elem.setAttribute('transform', rotate)
380
+ }
381
+ }
382
+ }
383
+ })
384
+ }
385
+ }
386
+
387
+ // TODO: create a 'typing' command object that tracks changes in text
388
+ // if a new Typing command is created and the top command on the stack is also a Typing
389
+ // and they both affect the same element, then collapse the two commands into one
390
+
391
+ /**
392
+ * History command that can contain/execute multiple other commands.
393
+ * @implements {module:history.HistoryCommand}
394
+ */
395
+ export class BatchCommand extends Command {
396
+ /**
397
+ * @param {string} [text] - An optional string visible to user related to this change
398
+ */
399
+ constructor (text) {
400
+ super()
401
+ this.text = text || 'Batch Command'
402
+ this.stack = []
403
+ }
404
+
405
+ /**
406
+ * Runs "apply" on all subcommands.
407
+ * @param {module:history.HistoryEventHandler} handler
408
+ * @fires module:history~Command#event:history
409
+ * @returns {void}
410
+ */
411
+ apply (handler) {
412
+ super.apply(handler, () => {
413
+ this.stack.forEach((stackItem) => {
414
+ console.assert(stackItem, 'stack item should not be null')
415
+ stackItem && stackItem.apply(handler)
416
+ })
417
+ })
418
+ }
419
+
420
+ /**
421
+ * Runs "unapply" on all subcommands.
422
+ * @param {module:history.HistoryEventHandler} handler
423
+ * @fires module:history~Command#event:history
424
+ * @returns {void}
425
+ */
426
+ unapply (handler) {
427
+ super.unapply(handler, () => {
428
+ this.stack.reverse().forEach((stackItem) => {
429
+ console.assert(stackItem, 'stack item should not be null')
430
+ stackItem && stackItem.unapply(handler)
431
+ })
432
+ })
433
+ }
434
+
435
+ /**
436
+ * Iterate through all our subcommands.
437
+ * @returns {Element[]} All the elements we are changing
438
+ */
439
+ elements () {
440
+ const elems = []
441
+ let cmd = this.stack.length
442
+ while (cmd--) {
443
+ if (!this.stack[cmd]) continue
444
+ const thisElems = this.stack[cmd].elements()
445
+ let elem = thisElems.length
446
+ while (elem--) {
447
+ if (!elems.includes(thisElems[elem])) { elems.push(thisElems[elem]) }
448
+ }
449
+ }
450
+ return elems
451
+ }
452
+
453
+ /**
454
+ * Adds a given command to the history stack.
455
+ * @param {Command} cmd - The undo command object to add
456
+ * @returns {void}
457
+ */
458
+ addSubCommand (cmd) {
459
+ console.assert(cmd !== null, 'cmd should not be null')
460
+ this.stack.push(cmd)
461
+ }
462
+
463
+ /**
464
+ * @returns {boolean} Indicates whether or not the batch command is empty
465
+ */
466
+ isEmpty () {
467
+ return !this.stack.length
468
+ }
469
+ }
470
+
471
+ /**
472
+ *
473
+ */
474
+ export class UndoManager {
475
+ /**
476
+ * @param {module:history.HistoryEventHandler} historyEventHandler
477
+ */
478
+ constructor (historyEventHandler) {
479
+ this.handler_ = historyEventHandler || null
480
+ this.undoStackPointer = 0
481
+ this.undoStack = []
482
+
483
+ // this is the stack that stores the original values, the elements and
484
+ // the attribute name for begin/finish
485
+ this.undoChangeStackPointer = -1
486
+ this.undoableChangeStack = []
487
+ }
488
+
489
+ /**
490
+ * Resets the undo stack, effectively clearing the undo/redo history.
491
+ * @returns {void}
492
+ */
493
+ resetUndoStack () {
494
+ this.undoStack = []
495
+ this.undoStackPointer = 0
496
+ }
497
+
498
+ /**
499
+ * @returns {Integer} Current size of the undo history stack
500
+ */
501
+ getUndoStackSize () {
502
+ return this.undoStackPointer
503
+ }
504
+
505
+ /**
506
+ * @returns {Integer} Current size of the redo history stack
507
+ */
508
+ getRedoStackSize () {
509
+ return this.undoStack.length - this.undoStackPointer
510
+ }
511
+
512
+ /**
513
+ * @returns {string} String associated with the next undo command
514
+ */
515
+ getNextUndoCommandText () {
516
+ return this.undoStackPointer > 0 ? this.undoStack[this.undoStackPointer - 1].getText() : ''
517
+ }
518
+
519
+ /**
520
+ * @returns {string} String associated with the next redo command
521
+ */
522
+ getNextRedoCommandText () {
523
+ return this.undoStackPointer < this.undoStack.length ? this.undoStack[this.undoStackPointer].getText() : ''
524
+ }
525
+
526
+ /**
527
+ * Performs an undo step.
528
+ * @returns {void}
529
+ */
530
+ undo () {
531
+ if (this.undoStackPointer > 0) {
532
+ const cmd = this.undoStack[--this.undoStackPointer]
533
+ cmd.unapply(this.handler_)
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Performs a redo step.
539
+ * @returns {void}
540
+ */
541
+ redo () {
542
+ if (this.undoStackPointer < this.undoStack.length && this.undoStack.length > 0) {
543
+ const cmd = this.undoStack[this.undoStackPointer++]
544
+ cmd.apply(this.handler_)
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Adds a command object to the undo history stack.
550
+ * @param {Command} cmd - The command object to add
551
+ * @returns {void}
552
+ */
553
+ addCommandToHistory (cmd) {
554
+ // TODO: we MUST compress consecutive text changes to the same element
555
+ // (right now each keystroke is saved as a separate command that includes the
556
+ // entire text contents of the text element)
557
+ // TODO: consider limiting the history that we store here (need to do some slicing)
558
+
559
+ // if our stack pointer is not at the end, then we have to remove
560
+ // all commands after the pointer and insert the new command
561
+ if (this.undoStackPointer < this.undoStack.length && this.undoStack.length > 0) {
562
+ this.undoStack = this.undoStack.splice(0, this.undoStackPointer)
563
+ }
564
+ this.undoStack.push(cmd)
565
+ this.undoStackPointer = this.undoStack.length
566
+ }
567
+
568
+ /**
569
+ * This function tells the canvas to remember the old values of the
570
+ * `attrName` attribute for each element sent in. The elements and values
571
+ * are stored on a stack, so the next call to `finishUndoableChange()` will
572
+ * pop the elements and old values off the stack, gets the current values
573
+ * from the DOM and uses all of these to construct the undo-able command.
574
+ * @param {string} attrName - The name of the attribute being changed
575
+ * @param {Element[]} elems - Array of DOM elements being changed
576
+ * @returns {void}
577
+ */
578
+ beginUndoableChange (attrName, elems) {
579
+ const p = ++this.undoChangeStackPointer
580
+ let i = elems.length
581
+ const oldValues = new Array(i); const elements = new Array(i)
582
+ while (i--) {
583
+ const elem = elems[i]
584
+ if (!elem) { continue }
585
+ elements[i] = elem
586
+ oldValues[i] = elem.getAttribute(attrName)
587
+ }
588
+ this.undoableChangeStack[p] = {
589
+ attrName,
590
+ oldValues,
591
+ elements
592
+ }
593
+ }
594
+
595
+ /**
596
+ * This function returns a `BatchCommand` object which summarizes the
597
+ * change since `beginUndoableChange` was called. The command can then
598
+ * be added to the command history.
599
+ * @returns {BatchCommand} Batch command object with resulting changes
600
+ */
601
+ finishUndoableChange () {
602
+ const p = this.undoChangeStackPointer--
603
+ const changeset = this.undoableChangeStack[p]
604
+ const { attrName } = changeset
605
+ const batchCmd = new BatchCommand('Change ' + attrName)
606
+ let i = changeset.elements.length
607
+ while (i--) {
608
+ const elem = changeset.elements[i]
609
+ if (!elem) { continue }
610
+ const changes = {}
611
+ changes[attrName] = changeset.oldValues[i]
612
+ if (changes[attrName] !== elem.getAttribute(attrName)) {
613
+ batchCmd.addSubCommand(new ChangeElementCommand(elem, changes, attrName))
614
+ }
615
+ }
616
+ this.undoableChangeStack[p] = null
617
+ return batchCmd
618
+ }
619
+ }