@themarioga/grid-editor 9.0.0-beta.2 → 9.0.0-beta.3

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.
@@ -178,6 +178,14 @@
178
178
  <span class="text-body-secondary">Copying a row, a column or a container and pasting it somewhere else, in this editor or another</span>
179
179
  </a>
180
180
 
181
+ <a class="list-group-item list-group-item-action" href="history.html">
182
+ <div class="d-flex justify-content-between">
183
+ <strong>Undo and redo</strong>
184
+ <code class="text-body-secondary">history.html</code>
185
+ </div>
186
+ <span class="text-body-secondary">The history plugin: Ctrl+Z and Ctrl+Y, and buttons on the toolbar, over what the editor changes</span>
187
+ </a>
188
+
181
189
  <a class="list-group-item list-group-item-action" href="wrap_content.html">
182
190
  <div class="d-flex justify-content-between">
183
191
  <strong>Wrapped content</strong>
package/package.json CHANGED
@@ -32,7 +32,7 @@
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
- "version": "9.0.0-beta.2",
35
+ "version": "9.0.0-beta.3",
36
36
  "main": "dist/grideditor.js",
37
37
  "module": "dist/grideditor.esm.js",
38
38
  "types": "dist/grideditor.d.ts",
@@ -1614,6 +1614,8 @@ function build(instance, baseElem, optionsOrMethod) {
1614
1614
  var colour = item.align === 'end' ? 'btn-primary' : 'btn-light';
1615
1615
  var button = labelButton(dom.element('a', { 'class': 'btn btn-sm ' + colour + ' ge-add-container ge-add-feature' }), label, iconClass);
1616
1616
 
1617
+ var acting = typeof item.action === 'function';
1618
+
1617
1619
  dom.addClass(button, item.className || '');
1618
1620
  // Hidden as it is now: a plugin that shows and hides its buttons
1619
1621
  // only hears of what changes, not of the toolbar being built again
@@ -1623,7 +1625,21 @@ function build(instance, baseElem, optionsOrMethod) {
1623
1625
  'data-ge-feature': name,
1624
1626
  'data-ge-item': index,
1625
1627
  });
1628
+ if (acting) {
1629
+ // Not a block to add: it is not carried onto the canvas, and
1630
+ // the active target is none of its business
1631
+ button.setAttribute('data-ge-action', '');
1632
+ if (typeof item.disabled === 'function' ? item.disabled() : item.disabled) {
1633
+ dom.addClass(button, 'disabled');
1634
+ button.setAttribute('aria-disabled', 'true');
1635
+ }
1636
+ }
1626
1637
  button.addEventListener('click', function() {
1638
+ if (acting) {
1639
+ if (!dom.hasClass(button, 'disabled')) { item.action(button); }
1640
+ return;
1641
+ }
1642
+
1627
1643
  if (activeTarget) { addToTarget(button, item.source || 'tool'); return; }
1628
1644
 
1629
1645
  var made = item.create();
@@ -1661,7 +1677,7 @@ function build(instance, baseElem, optionsOrMethod) {
1661
1677
  }
1662
1678
 
1663
1679
  function makeToolbarDraggable() {
1664
- var buttons = dom.all(mainControls, '[data-ge-toolbar]');
1680
+ var buttons = dom.all(mainControls, '[data-ge-toolbar]:not([data-ge-action])');
1665
1681
 
1666
1682
  buttons.forEach(function(button) {
1667
1683
  dom.removeClass(button, 'ge-palette-button');
@@ -2624,6 +2640,19 @@ function build(instance, baseElem, optionsOrMethod) {
2624
2640
  },
2625
2641
  getSelected: function() { return panelMode() === 'sidebar' ? selected : null; },
2626
2642
  getActiveTarget: function() { return settings.active_target ? activeTarget : null; },
2643
+ // True when it took: a node that cannot be, or no setting
2644
+ // for it, says nothing, since a plugin putting back what it
2645
+ // remembers cannot know the node is still one
2646
+ setSelected: function(node) {
2647
+ var chosen = panelMode() === 'sidebar' && !!node && node !== canvas && canvas.contains(node) && detailsFor.has(node);
2648
+ if (chosen) { changeSelection(node); }
2649
+ return chosen;
2650
+ },
2651
+ setActiveTarget: function(node) {
2652
+ var chosen = !!settings.active_target && isTarget(node);
2653
+ if (chosen) { changeTarget(node); }
2654
+ return chosen;
2655
+ },
2627
2656
  // What keeps Escape from the selection and the target, and an
2628
2657
  // inline panel unfolded in a drawer, which Escape leaves alone
2629
2658
  panelOpen: function() {
@@ -175,6 +175,8 @@ GridEditor.locales.es = {
175
175
  'autosave.restore_message': 'Hay un borrador de esta página guardado el {date}, con cambios que no se publicaron. ¿Quieres recuperarlo?',
176
176
  'autosave.restore': 'Recuperar',
177
177
  'autosave.discard': 'Descartar',
178
+ 'history.undo': 'Deshacer',
179
+ 'history.redo': 'Rehacer',
178
180
  'utility.col_from_row': 'De la fila: {count}',
179
181
  'utility.row_cols': 'Columnas por fila',
180
182
  'badge.row_cols': '{count} por fila',
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Undo and redo for grid-editor.
3
+ *
4
+ * A feature plugin: load this file after the editor and what is edited is
5
+ * kept, step by step, in memory, so Ctrl+Z goes back to how it was and
6
+ * Ctrl+Y (or Ctrl+Shift+Z) comes forward again. Two buttons on the right of
7
+ * the toolbar do the same. What it can ask the editor for is the handle its
8
+ * factory is called with, described in docs/plugins.md.
9
+ *
10
+ * <script src="dist/grideditor.min.js"></script>
11
+ * <script src="dist/plugins/grideditor.history.min.js"></script>
12
+ *
13
+ * A step is the canvas's markup as ge.snapshotHtml() gives it, kept `delay`
14
+ * ms after the last change: a drag or a burst of typing is one step. Going
15
+ * to one puts its markup back with ge.setHtml(), and the node that was
16
+ * selected, and the target, are chosen again by where they were.
17
+ *
18
+ * Typing in a text is the text editor's own to undo while it has the focus:
19
+ * the keys are left alone then, and what was typed becomes a step when it
20
+ * is done.
21
+ *
22
+ * new GridEditor('#myGrid', { history: { max: 100, buttons: false } });
23
+ */
24
+ import { GridEditor } from '../grideditor.js';
25
+ import * as dom from '../dom.js';
26
+
27
+ Object.assign(GridEditor.locales.en, {
28
+ 'history.undo': 'Undo',
29
+ 'history.redo': 'Redo',
30
+ });
31
+
32
+ var DEFAULT_MAX = 50;
33
+ var DEFAULT_DELAY = 500;
34
+
35
+ /** What the editor puts in a node that is not the node's own: a position counts without it. */
36
+ var FURNITURE = '.ge-tools-drawer, .ge-drop-marker';
37
+
38
+ /** What a drag or a resize leaves on the canvas while it lasts. */
39
+ var BUSY = '.ge-drag-chosen, .ge-drag-placeholder, .ge-drag-helper, .ge-resizing';
40
+
41
+ /** The editors listening for the keys, and the one used last, which a key press goes to. */
42
+ var listeningEditors = [];
43
+ var lastEditor = null;
44
+
45
+ function track(e) {
46
+ lastEditor = listeningEditors.filter(function(ge) { return ge.contains(e.target); })[0] || null;
47
+ }
48
+
49
+ // Captured: a control that stops the event still counts as using its editor
50
+ document.addEventListener('pointerdown', track, true);
51
+ document.addEventListener('focusin', track, true);
52
+
53
+ /** 'z', 'y' or null, from the letter typed or, on a layout without Latin letters, the key's place. */
54
+ function shortcutKey(e) {
55
+ var key = (e.key || '').toLowerCase();
56
+ if (!/^[a-z]$/.test(key)) { key = { KeyZ: 'z', KeyY: 'y' }[e.code] || ''; }
57
+
58
+ return key === 'z' || key === 'y' ? key : null;
59
+ }
60
+
61
+ function typing() {
62
+ var focus = document.activeElement;
63
+ return !!focus && (dom.is(focus, 'input, textarea, select') || focus.isContentEditable);
64
+ }
65
+
66
+ /** The children of `parent` that are its own content, without the editor's furniture. */
67
+ function contentOf(parent) {
68
+ return dom.children(parent).filter(function(child) { return !dom.is(child, FURNITURE); });
69
+ }
70
+
71
+ GridEditor.features.history = function(ge) {
72
+
73
+ var options = optionsFrom(ge.settings.history || {});
74
+
75
+ var stack = []; // The markup of each step, oldest first
76
+ var index = -1; // The step the canvas is at
77
+ var shown = { canUndo: false, canRedo: false }; // What the last history-change said
78
+ var timer = null;
79
+ var observer = null;
80
+ var observing = false;
81
+ var initiated = false; // Whether the first init has been looked at
82
+ var started = false; // Whether there is a first step to go back to
83
+ var sourceOpen = false;
84
+ var destroyed = false;
85
+
86
+ function optionsFrom(given) {
87
+ var result = {
88
+ shortcuts: given.shortcuts === undefined ? true : given.shortcuts,
89
+ buttons: given.buttons === undefined ? true : given.buttons,
90
+ max: given.max === undefined ? DEFAULT_MAX : given.max,
91
+ delay: given.delay === undefined ? DEFAULT_DELAY : given.delay,
92
+ };
93
+
94
+ ['shortcuts', 'buttons'].forEach(function(name) {
95
+ if (typeof result[name] !== 'boolean') {
96
+ ge.warn('history.' + name + ' is true or false: true is used');
97
+ result[name] = true;
98
+ }
99
+ });
100
+ if (!Number.isInteger(result.max) || result.max < 1) {
101
+ ge.warn('history.max must be a whole number of steps, 1 or more: ' + DEFAULT_MAX + ' is used');
102
+ result.max = DEFAULT_MAX;
103
+ }
104
+ if (!Number.isInteger(result.delay) || result.delay < 0) {
105
+ ge.warn('history.delay must be a whole number of milliseconds, 0 or more: ' + DEFAULT_DELAY + ' is used');
106
+ result.delay = DEFAULT_DELAY;
107
+ }
108
+
109
+ return result;
110
+ }
111
+
112
+ function observe() {
113
+ observer.observe(ge.canvas, { subtree: true, childList: true, attributes: true, characterData: true });
114
+ observing = true;
115
+ }
116
+
117
+ function stopObserving() {
118
+ if (observer) { observer.disconnect(); }
119
+ observing = false;
120
+ }
121
+
122
+ /** The canvas's markup now. Reading may change the canvas, when a plugin cannot be read on a copy, and that is no change of the user's. */
123
+ function read() {
124
+ var watching = observing;
125
+ if (watching) { stopObserving(); }
126
+ var html = ge.snapshotHtml();
127
+ if (watching) { observe(); }
128
+
129
+ return html;
130
+ }
131
+
132
+ function editing() {
133
+ return dom.hasClass(ge.canvas, 'ge-editing');
134
+ }
135
+
136
+ /** A drag or a resize is going on: the canvas is not at a step but between two. */
137
+ function busy() {
138
+ return dom.hasClass(ge.canvas, 'ge-dropping') || !!dom.one(ge.canvas, BUSY);
139
+ }
140
+
141
+ function schedule() {
142
+ window.clearTimeout(timer);
143
+ timer = window.setTimeout(commit, options.delay);
144
+ }
145
+
146
+ /** Whether something changed since the last step that is not a step yet. */
147
+ function pending() {
148
+ return timer !== null && read() !== stack[index];
149
+ }
150
+
151
+ /** What is on the canvas, if it is not the step it is at, becomes the next one. */
152
+ function commit() {
153
+ window.clearTimeout(timer);
154
+ timer = null;
155
+
156
+ if (!started || destroyed || sourceOpen) { return; }
157
+ if (busy()) { schedule(); return; }
158
+
159
+ var html = read();
160
+ if (html === stack[index]) { return; }
161
+
162
+ stack.splice(index + 1);
163
+ stack.push(html);
164
+ if (stack.length > options.max + 1) { stack.shift(); }
165
+ index = stack.length - 1;
166
+
167
+ notify();
168
+ }
169
+
170
+ /** The buttons, and a history-change when what can be done is not what was last said. */
171
+ function notify() {
172
+ var now = { canUndo: index > 0, canRedo: index < stack.length - 1 };
173
+ var changed = now.canUndo !== shown.canUndo || now.canRedo !== shown.canRedo;
174
+
175
+ shown = now;
176
+ paint();
177
+
178
+ if (changed) {
179
+ ge.emit('history-change', { canvas: ge.canvas, canUndo: now.canUndo, canRedo: now.canRedo });
180
+ }
181
+ }
182
+
183
+ function paint() {
184
+ ge.toolbarItems('history').forEach(function(button) {
185
+ var off = button.getAttribute('data-ge-item') === '0' ? !shown.canUndo : !shown.canRedo;
186
+
187
+ dom.toggleClass(button, 'disabled', off);
188
+ if (off) {
189
+ button.setAttribute('aria-disabled', 'true');
190
+ } else {
191
+ button.removeAttribute('aria-disabled');
192
+ }
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Where a node is: its index among its parent's content, all the way up
198
+ * from the canvas. The canvas is made again from markup, so a node is
199
+ * not the same one afterwards, but the place it was in is.
200
+ */
201
+ function pathOf(node) {
202
+ if (!node || node === ge.canvas || !ge.canvas.contains(node)) { return null; }
203
+
204
+ var path = [];
205
+ for (var at = node; at !== ge.canvas; at = at.parentElement) {
206
+ path.unshift(contentOf(at.parentElement).indexOf(at));
207
+ }
208
+
209
+ return path;
210
+ }
211
+
212
+ function resolve(path) {
213
+ if (!path) { return null; }
214
+
215
+ var node = ge.canvas;
216
+ for (var i = 0; i < path.length && node; i++) { node = contentOf(node)[path[i]]; }
217
+
218
+ return node || null;
219
+ }
220
+
221
+ function go(step, source) {
222
+ if (!started || destroyed || sourceOpen || !editing() || busy()) { return false; }
223
+
224
+ // A change not yet a step is a step of its own, and the one undone
225
+ commit();
226
+
227
+ var to = index + step;
228
+ if (to < 0 || to >= stack.length) { return false; }
229
+
230
+ var selected = pathOf(ge.getSelected());
231
+ var target = pathOf(ge.getActiveTarget());
232
+
233
+ stopObserving();
234
+ index = to;
235
+ ge.setHtml(stack[index]);
236
+ // The step as the canvas reads it back, so that is what is compared with
237
+ stack[index] = ge.snapshotHtml();
238
+
239
+ var node = resolve(selected);
240
+ if (node) { ge.setSelected(node); }
241
+ node = resolve(target);
242
+ if (node) { ge.setActiveTarget(node); }
243
+
244
+ observe();
245
+ notify();
246
+ ge.emit(step < 0 ? 'after-undo' : 'after-redo', { canvas: ge.canvas, html: stack[index], source: source });
247
+
248
+ return true;
249
+ }
250
+
251
+ function onKeydown(e) {
252
+ if (lastEditor !== ge || e.altKey || !(e.ctrlKey || e.metaKey)) { return; }
253
+
254
+ var key = shortcutKey(e);
255
+ if (!key || (key === 'y' && e.shiftKey)) { return; }
256
+ if (!editing() || typing() || ge.panelOpen()) { return; }
257
+
258
+ // Left alone, it is the browser's
259
+ if (go(key === 'y' || e.shiftKey ? 1 : -1, 'shortcut')) { e.preventDefault(); }
260
+ }
261
+
262
+ function listenForShortcuts(on) {
263
+ var at = listeningEditors.indexOf(ge);
264
+
265
+ if (on && at === -1) {
266
+ listeningEditors.push(ge);
267
+ document.addEventListener('keydown', onKeydown);
268
+ } else if (!on && at !== -1) {
269
+ listeningEditors.splice(at, 1);
270
+ document.removeEventListener('keydown', onKeydown);
271
+ }
272
+ }
273
+
274
+ /** After the first init has finished: the canvas is ready to be read. */
275
+ function start() {
276
+ if (destroyed) { return; }
277
+
278
+ stack = [read()];
279
+ index = 0;
280
+ observer = new MutationObserver(schedule);
281
+ observe();
282
+ started = true;
283
+ notify();
284
+ }
285
+
286
+ return {
287
+ toolbar: function() {
288
+ if (!options.buttons) { return []; }
289
+
290
+ return [
291
+ {
292
+ labelKey: 'history.undo',
293
+ iconClass: 'bi bi-arrow-counterclockwise',
294
+ className: 'ge-history-undo',
295
+ align: 'end',
296
+ disabled: function() { return !shown.canUndo; },
297
+ action: function() { go(-1, 'tool'); },
298
+ },
299
+ {
300
+ labelKey: 'history.redo',
301
+ iconClass: 'bi bi-arrow-clockwise',
302
+ className: 'ge-history-redo',
303
+ align: 'end',
304
+ disabled: function() { return !shown.canRedo; },
305
+ action: function() { go(1, 'tool'); },
306
+ },
307
+ ];
308
+ },
309
+
310
+ onInit: function() {
311
+ if (options.shortcuts) { listenForShortcuts(true); }
312
+
313
+ if (initiated) { return; }
314
+ initiated = true;
315
+ Promise.resolve().then(start);
316
+ },
317
+
318
+ // A canvas out of editing leaves the keys to the page: it is read again when it is back
319
+ onDeinit: function() { listenForShortcuts(false); },
320
+
321
+ onSourceOpen: function() { sourceOpen = true; },
322
+ onSourceClose: function() { sourceOpen = false; },
323
+
324
+ onDestroy: function() {
325
+ destroyed = true;
326
+ window.clearTimeout(timer);
327
+ timer = null;
328
+ stopObserving();
329
+ listenForShortcuts(false);
330
+ if (lastEditor === ge) { lastEditor = null; }
331
+ stack = [];
332
+ },
333
+
334
+ // Nothing of its own on the canvas
335
+ cleanMarkup: function() {},
336
+
337
+ methods: {
338
+ undo: function() { return go(-1, 'api'); },
339
+
340
+ redo: function() { return go(1, 'api'); },
341
+
342
+ // A change waiting for its step counts: it is what an undo would undo
343
+ canUndo: function() {
344
+ return started && !destroyed && (index > 0 || pending());
345
+ },
346
+
347
+ // ... and it ends what could be redone
348
+ canRedo: function() {
349
+ return started && !destroyed && index < stack.length - 1 && !pending();
350
+ },
351
+
352
+ // What is there is the first step, with nothing before it or after it
353
+ clearHistory: function() {
354
+ if (!started || destroyed) { return false; }
355
+
356
+ window.clearTimeout(timer);
357
+ timer = null;
358
+ // The textarea's markup is not the canvas's while the source is open
359
+ stack = [sourceOpen ? stack[index] : read()];
360
+ index = 0;
361
+ notify();
362
+
363
+ return true;
364
+ },
365
+ },
366
+ };
367
+ };
@@ -180,6 +180,8 @@ export interface GridEditorOptions {
180
180
  autosave?: AutosaveOptions;
181
181
  /** The clipboard plugin. `shortcuts`: Ctrl+C and Ctrl+V on the node selected or the target; default active_target. */
182
182
  clipboard?: { shortcuts?: boolean };
183
+ /** The history plugin: undo and redo. */
184
+ history?: HistoryOptions;
183
185
  custom_filter?: CustomFilter | CustomFilter[] | '';
184
186
  valid_col_sizes?: ColumnSize[];
185
187
  valid_col_offsets?: number[];
@@ -222,6 +224,18 @@ export interface AutosaveOptions {
222
224
  maxAge?: number | null;
223
225
  }
224
226
 
227
+ /** The history plugin's options. */
228
+ export interface HistoryOptions {
229
+ /** Ctrl+Z, and Ctrl+Y or Ctrl+Shift+Z (⌘ on a Mac). Default true. */
230
+ shortcuts?: boolean;
231
+ /** The undo and redo buttons on the right of the toolbar. Default true. */
232
+ buttons?: boolean;
233
+ /** The steps that can be undone. Default 50. */
234
+ max?: number;
235
+ /** Milliseconds after the last change that make a step. Default 500. */
236
+ delay?: number;
237
+ }
238
+
225
239
  /** A draft the autosave plugin kept. */
226
240
  export interface AutosaveDraft {
227
241
  html: string;
@@ -252,6 +266,19 @@ export interface AutosaveErrorPayload {
252
266
  error: unknown;
253
267
  }
254
268
 
269
+ export interface HistoryPayload {
270
+ canvas: HTMLElement;
271
+ /** The markup the canvas went to. */
272
+ html: string;
273
+ source: 'shortcut' | 'tool' | 'api';
274
+ }
275
+
276
+ export interface HistoryChangePayload {
277
+ canvas: HTMLElement;
278
+ canUndo: boolean;
279
+ canRedo: boolean;
280
+ }
281
+
255
282
  /** Where a node was, or went: its parent and its index among the blocks there. */
256
283
  export interface Position {
257
284
  parent: HTMLElement;
@@ -385,6 +412,9 @@ export interface GridEditorEventMap {
385
412
  'grideditor:after-autosave': AutosavePayload;
386
413
  'grideditor:after-restore-draft': RestoreDraftPayload;
387
414
  'grideditor:autosave-error': AutosaveErrorPayload;
415
+ 'grideditor:after-undo': HistoryPayload;
416
+ 'grideditor:after-redo': HistoryPayload;
417
+ 'grideditor:history-change': HistoryChangePayload;
388
418
  }
389
419
 
390
420
  /** An event the editor dispatches on its canvas: a CustomEvent whose detail is the payload. */
@@ -523,6 +553,14 @@ export declare class GridEditor {
523
553
  saveDraft?(): boolean;
524
554
  getDraft?(): AutosaveDraft | null;
525
555
  clearDraft?(): boolean;
556
+ /** The history plugin's, when it is loaded: true when it went to another step. */
557
+ undo?(): boolean;
558
+ redo?(): boolean;
559
+ /** Whether there is a step to go back to, a change not yet a step counting. */
560
+ canUndo?(): boolean;
561
+ canRedo?(): boolean;
562
+ /** What there is now becomes the first step, with nothing before or after it. */
563
+ clearHistory?(): boolean;
526
564
  }
527
565
 
528
566
  export default GridEditor;