@themarioga/grid-editor 8.5.0 → 8.6.0

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +78 -4
  3. package/dist/grideditor.bundle.min.js +3 -3
  4. package/dist/grideditor.css +15 -4
  5. package/dist/grideditor.d.ts +59 -3
  6. package/dist/grideditor.esm.js +200 -36
  7. package/dist/grideditor.js +200 -36
  8. package/dist/grideditor.min.css +1 -1
  9. package/dist/grideditor.min.css.map +1 -1
  10. package/dist/grideditor.min.js +2 -2
  11. package/dist/grideditor.min.js.map +3 -3
  12. package/dist/locales/grideditor.es.esm.js +4 -0
  13. package/dist/locales/grideditor.es.js +4 -0
  14. package/dist/locales/grideditor.es.min.js +1 -1
  15. package/dist/locales/grideditor.es.min.js.map +2 -2
  16. package/dist/plugins/grideditor.accordion.esm.js +7 -0
  17. package/dist/plugins/grideditor.accordion.js +7 -0
  18. package/dist/plugins/grideditor.accordion.min.js +1 -1
  19. package/dist/plugins/grideditor.accordion.min.js.map +3 -3
  20. package/dist/plugins/grideditor.autosave.esm.js +309 -0
  21. package/dist/plugins/grideditor.autosave.js +316 -0
  22. package/dist/plugins/grideditor.autosave.min.js +1 -0
  23. package/dist/plugins/grideditor.autosave.min.js.map +7 -0
  24. package/dist/plugins/grideditor.card.esm.js +10 -0
  25. package/dist/plugins/grideditor.card.js +10 -0
  26. package/dist/plugins/grideditor.card.min.js +1 -1
  27. package/dist/plugins/grideditor.card.min.js.map +3 -3
  28. package/dist/plugins/grideditor.ckeditor.esm.js +29 -0
  29. package/dist/plugins/grideditor.ckeditor.js +29 -0
  30. package/dist/plugins/grideditor.ckeditor.min.js +1 -1
  31. package/dist/plugins/grideditor.ckeditor.min.js.map +3 -3
  32. package/dist/plugins/grideditor.clipboard.esm.js +3 -0
  33. package/dist/plugins/grideditor.clipboard.js +3 -0
  34. package/dist/plugins/grideditor.clipboard.min.js +1 -1
  35. package/dist/plugins/grideditor.clipboard.min.js.map +2 -2
  36. package/dist/plugins/grideditor.codemirror-inline.esm.js +11 -0
  37. package/dist/plugins/grideditor.codemirror-inline.js +11 -0
  38. package/dist/plugins/grideditor.codemirror-inline.min.js +1 -1
  39. package/dist/plugins/grideditor.codemirror-inline.min.js.map +3 -3
  40. package/dist/plugins/grideditor.elements.esm.js +6 -3
  41. package/dist/plugins/grideditor.elements.js +6 -3
  42. package/dist/plugins/grideditor.elements.min.js +1 -1
  43. package/dist/plugins/grideditor.elements.min.js.map +3 -3
  44. package/dist/plugins/grideditor.gutters.esm.js +5 -0
  45. package/dist/plugins/grideditor.gutters.js +5 -0
  46. package/dist/plugins/grideditor.gutters.min.js +1 -1
  47. package/dist/plugins/grideditor.gutters.min.js.map +3 -3
  48. package/dist/plugins/grideditor.inline-style.esm.js +18 -10
  49. package/dist/plugins/grideditor.inline-style.js +18 -10
  50. package/dist/plugins/grideditor.inline-style.min.js +3 -3
  51. package/dist/plugins/grideditor.inline-style.min.js.map +3 -3
  52. package/dist/plugins/grideditor.order.esm.js +5 -0
  53. package/dist/plugins/grideditor.order.js +5 -0
  54. package/dist/plugins/grideditor.order.min.js +1 -1
  55. package/dist/plugins/grideditor.order.min.js.map +3 -3
  56. package/dist/plugins/grideditor.popup.esm.js +17 -6
  57. package/dist/plugins/grideditor.popup.js +17 -6
  58. package/dist/plugins/grideditor.popup.min.js +1 -1
  59. package/dist/plugins/grideditor.popup.min.js.map +3 -3
  60. package/dist/plugins/grideditor.sections.esm.js +6 -3
  61. package/dist/plugins/grideditor.sections.js +6 -3
  62. package/dist/plugins/grideditor.sections.min.js +1 -1
  63. package/dist/plugins/grideditor.sections.min.js.map +3 -3
  64. package/dist/plugins/grideditor.summernote.esm.js +39 -0
  65. package/dist/plugins/grideditor.summernote.js +39 -0
  66. package/dist/plugins/grideditor.summernote.min.js +1 -1
  67. package/dist/plugins/grideditor.summernote.min.js.map +3 -3
  68. package/dist/plugins/grideditor.tabs.esm.js +7 -0
  69. package/dist/plugins/grideditor.tabs.js +7 -0
  70. package/dist/plugins/grideditor.tabs.min.js +1 -1
  71. package/dist/plugins/grideditor.tabs.min.js.map +3 -3
  72. package/dist/plugins/grideditor.tinymce.esm.js +31 -0
  73. package/dist/plugins/grideditor.tinymce.js +31 -0
  74. package/dist/plugins/grideditor.tinymce.min.js +1 -1
  75. package/dist/plugins/grideditor.tinymce.min.js.map +3 -3
  76. package/docs/events.md +8 -0
  77. package/docs/locale-keys.md +14 -0
  78. package/docs/plugins.md +54 -2
  79. package/example/autosave.html +39 -22
  80. package/example/index.html +1 -1
  81. package/example/plugins.html +10 -0
  82. package/package.json +1 -2
  83. package/src/js/grideditor.js +242 -28
  84. package/src/js/inline-style/display.js +4 -3
  85. package/src/js/inline-style/drawerflow.js +10 -5
  86. package/src/js/locales/grideditor.es.js +4 -0
  87. package/src/js/plugins/grideditor.accordion.js +6 -0
  88. package/src/js/plugins/grideditor.autosave.js +349 -0
  89. package/src/js/plugins/grideditor.card.js +6 -0
  90. package/src/js/plugins/grideditor.ckeditor.js +6 -0
  91. package/src/js/plugins/grideditor.clipboard.js +3 -0
  92. package/src/js/plugins/grideditor.codemirror-inline.js +7 -0
  93. package/src/js/plugins/grideditor.elements.js +4 -3
  94. package/src/js/plugins/grideditor.gutters.js +4 -0
  95. package/src/js/plugins/grideditor.inline-style.js +2 -0
  96. package/src/js/plugins/grideditor.order.js +4 -0
  97. package/src/js/plugins/grideditor.popup.js +15 -6
  98. package/src/js/plugins/grideditor.sections.js +4 -3
  99. package/src/js/plugins/grideditor.summernote.js +14 -0
  100. package/src/js/plugins/grideditor.tabs.js +6 -0
  101. package/src/js/plugins/grideditor.tinymce.js +8 -0
  102. package/src/js/text/grideditor.text.js +26 -0
  103. package/src/less/grideditor.less +16 -8
  104. package/types/grideditor.d.ts +59 -3
  105. package/AUTO_SAVE.md +0 -30
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Autosave for grid-editor.
3
+ *
4
+ * A feature plugin: load this file after the editor and what is being edited
5
+ * is kept in the browser's storage as it changes, and offered back the next
6
+ * time an editor starts from the same html. What it can ask the editor for is
7
+ * the handle its factory is called with, described in docs/plugins.md.
8
+ *
9
+ * <script src="dist/grideditor.min.js"></script>
10
+ * <script src="dist/plugins/grideditor.autosave.min.js"></script>
11
+ *
12
+ * It reads the canvas with ge.snapshotHtml(), which leaves the canvas
13
+ * editing: the text being typed into stays open, with its cursor. A save
14
+ * comes `delay` ms after the last change, and when the page is left.
15
+ *
16
+ * A draft remembers the html its editor started from, and is only offered
17
+ * to an editor that starts from the same html: a page that loads one
18
+ * document after another is not offered one document's draft over another.
19
+ * Such a page gives each document a key of its own, and each keeps its draft.
20
+ *
21
+ * new GridEditor('#myGrid', { autosave: { key: 'page-42', storage: 'session' } });
22
+ */
23
+ import { GridEditor } from '../grideditor.js';
24
+
25
+ Object.assign(GridEditor.locales.en, {
26
+ 'autosave.restore_title': 'Restore the draft?',
27
+ 'autosave.restore_message': 'There is a draft of this page saved on {date}, with changes that were not published. Restore it?',
28
+ 'autosave.restore': 'Restore',
29
+ 'autosave.discard': 'Discard',
30
+ });
31
+
32
+ var VERSION = 1;
33
+ var STORAGES = { local: 'localStorage', session: 'sessionStorage' };
34
+ var DEFAULT_DELAY = 1000;
35
+
36
+ /** The keys the editors on this page save to: one editor to a key. */
37
+ var liveKeys = new Set();
38
+
39
+ /** FNV-1a, 32 bits, as hex: enough to tell one document's html from another's. */
40
+ function fingerprint(text) {
41
+ var hash = 0x811c9dc5;
42
+
43
+ for (var i = 0; i < text.length; i++) {
44
+ hash ^= text.charCodeAt(i);
45
+ hash = Math.imul(hash, 0x01000193);
46
+ }
47
+
48
+ return (hash >>> 0).toString(16).padStart(8, '0');
49
+ }
50
+
51
+ /** A stored value that is a draft this file wrote, or null. */
52
+ function validDraft(value) {
53
+ return !!value && typeof value === 'object' && value.version === VERSION &&
54
+ typeof value.html === 'string' && typeof value.base === 'string' &&
55
+ typeof value.savedAt === 'string' && !isNaN(Date.parse(value.savedAt));
56
+ }
57
+
58
+ GridEditor.features.autosave = function(ge) {
59
+
60
+ var options = optionsFrom(ge.settings.autosave || {});
61
+ var key = options.key || 'grideditor.autosave:' + window.location.pathname + '#' + ge.canvas.id;
62
+ var storage = storageFor(options.storage);
63
+
64
+ // unavailable | disabled | asking | enabled | destroyed
65
+ var state = 'disabled';
66
+ var enabledAfterAsking = true; // What asking ends in: a method may have changed it meanwhile
67
+ var started = false; // Whether the first init has been looked at
68
+ var draft = null; // The draft found when the editor was made, while it may be offered
69
+ var base = null; // The fingerprint of the html the editor started from
70
+ var last = null; // The html saved last, or the one there was to start from
71
+ var timer = null;
72
+ var observer = null;
73
+ var sourceOpen = false;
74
+ var warnedWrite = false;
75
+
76
+ if (!storage) {
77
+ ge.warn('autosave: the browser will not give this page its ' + STORAGES[options.storage] +
78
+ ', so nothing is saved');
79
+ state = 'unavailable';
80
+ } else if (liveKeys.has(key)) {
81
+ ge.warn('autosave: another editor on this page already saves to "' + key + '", so this one does not; ' +
82
+ 'give each its own autosave.key');
83
+ state = 'unavailable';
84
+ } else {
85
+ liveKeys.add(key);
86
+ if (options.enabled) {
87
+ state = 'enabled';
88
+ draft = readDraft(true);
89
+ }
90
+ }
91
+
92
+ function optionsFrom(given) {
93
+ var result = {
94
+ enabled: given.enabled === undefined ? true : !!given.enabled,
95
+ storage: given.storage === undefined ? 'local' : given.storage,
96
+ key: given.key === undefined ? null : given.key,
97
+ delay: given.delay === undefined ? DEFAULT_DELAY : given.delay,
98
+ maxAge: given.maxAge === undefined ? null : given.maxAge,
99
+ };
100
+
101
+ if (!STORAGES[result.storage]) {
102
+ ge.warn('autosave.storage "' + result.storage + '" is not local or session: local is used');
103
+ result.storage = 'local';
104
+ }
105
+ if (result.key !== null && (typeof result.key !== 'string' || result.key === '')) {
106
+ ge.warn('autosave.key must be a string: the page\'s own key is used');
107
+ result.key = null;
108
+ }
109
+ if (typeof result.delay !== 'number' || !Number.isInteger(result.delay) || result.delay < 0) {
110
+ ge.warn('autosave.delay must be a whole number of milliseconds, 0 or more: ' + DEFAULT_DELAY + ' is used');
111
+ result.delay = DEFAULT_DELAY;
112
+ }
113
+ if (result.maxAge !== null && !(typeof result.maxAge === 'number' && isFinite(result.maxAge) && result.maxAge > 0)) {
114
+ ge.warn('autosave.maxAge must be a number of milliseconds over 0, or null: drafts do not expire');
115
+ result.maxAge = null;
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ /** The storage, if the browser lets the page have it. */
122
+ function storageFor(name) {
123
+ try {
124
+ var found = window[STORAGES[name]];
125
+ found.getItem(key);
126
+ return found;
127
+ } catch (error) {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The draft saved under the key, or null. Starting, one that cannot be
134
+ * read or has expired is taken away: nothing will ever offer it.
135
+ */
136
+ function readDraft(starting) {
137
+ var raw;
138
+
139
+ try {
140
+ raw = storage.getItem(key);
141
+ } catch (error) {
142
+ return null;
143
+ }
144
+ if (raw === null) { return null; }
145
+
146
+ var value = null;
147
+ try { value = JSON.parse(raw); } catch (error) { /* not ours, or mangled */ }
148
+
149
+ if (!validDraft(value)) {
150
+ if (starting) {
151
+ ge.warn('autosave: what is saved under "' + key + '" is not a draft this version can read: it is discarded');
152
+ remove();
153
+ }
154
+ return null;
155
+ }
156
+
157
+ if (starting && options.maxAge !== null && Date.now() - Date.parse(value.savedAt) > options.maxAge) {
158
+ remove();
159
+ return null;
160
+ }
161
+
162
+ return value;
163
+ }
164
+
165
+ function remove() {
166
+ try { storage.removeItem(key); } catch (error) { /* nothing to take away */ }
167
+ }
168
+
169
+ /** Write the canvas as it is, if it changed. True when it was written. */
170
+ function save(source) {
171
+ window.clearTimeout(timer);
172
+ timer = null;
173
+
174
+ if (sourceOpen || base === null) { return false; }
175
+
176
+ // The read itself may change the canvas - getHtml, when a plugin
177
+ // cannot be read on a copy - and that is not a change to save
178
+ var watching = !!observer;
179
+ if (watching) { observer.disconnect(); }
180
+ var html = ge.snapshotHtml();
181
+ if (watching) { watch(); }
182
+
183
+ if (html === last) { return false; }
184
+
185
+ var value = { version: VERSION, html: html, savedAt: new Date().toISOString(), base: base };
186
+
187
+ try {
188
+ storage.setItem(key, JSON.stringify(value));
189
+ } catch (error) {
190
+ if (!warnedWrite) {
191
+ warnedWrite = true;
192
+ ge.warn('autosave: the draft could not be saved (' + (error && error.message || error) + ')');
193
+ }
194
+ ge.emit('autosave-error', { canvas: ge.canvas, error: error });
195
+ return false;
196
+ }
197
+
198
+ last = html;
199
+ ge.emit('after-autosave', { canvas: ge.canvas, html: html, savedAt: value.savedAt, source: source });
200
+ return true;
201
+ }
202
+
203
+ function schedule() {
204
+ window.clearTimeout(timer);
205
+ timer = window.setTimeout(function() { save('change'); }, options.delay);
206
+ }
207
+
208
+ function watch() {
209
+ if (!observer) { observer = new MutationObserver(schedule); }
210
+ observer.observe(ge.canvas, { subtree: true, childList: true, attributes: true, characterData: true });
211
+ }
212
+
213
+ function unwatch() {
214
+ window.clearTimeout(timer);
215
+ timer = null;
216
+ if (observer) { observer.disconnect(); }
217
+ }
218
+
219
+ function onPageHide() {
220
+ if (state === 'enabled') { save('pagehide'); }
221
+ }
222
+
223
+ /** After the first init has finished: what there is to start from, and the draft offered. */
224
+ function start() {
225
+ if (state === 'destroyed' || state === 'unavailable') { return; }
226
+
227
+ last = ge.snapshotHtml();
228
+ base = fingerprint(last);
229
+ window.addEventListener('pagehide', onPageHide);
230
+
231
+ // One of another document, or the same as what is there, is not offered
232
+ if (state === 'enabled' && draft && draft.base === base && draft.html !== last) {
233
+ ask(draft);
234
+ return;
235
+ }
236
+
237
+ draft = null;
238
+ if (state === 'enabled') { watch(); }
239
+ }
240
+
241
+ function dateOf(savedAt) {
242
+ try {
243
+ return new Date(savedAt).toLocaleString(ge.settings.locale || undefined);
244
+ } catch (error) {
245
+ return new Date(savedAt).toLocaleString();
246
+ }
247
+ }
248
+
249
+ function ask(found) {
250
+ state = 'asking';
251
+
252
+ ge.confirm(ge.t('autosave.restore_message', { date: dateOf(found.savedAt) }), {
253
+ title: ge.t('autosave.restore_title'),
254
+ ok: ge.t('autosave.restore'),
255
+ cancel: ge.t('autosave.discard'),
256
+ }, function(restore) {
257
+ if (state === 'destroyed') { return; }
258
+
259
+ // Taken away unanswered - setLocale - so asked again, in the new
260
+ // language, once the old modal is out of the way
261
+ if (restore === null) {
262
+ window.setTimeout(function() {
263
+ if (state === 'asking') { ask(found); }
264
+ }, 0);
265
+ return;
266
+ }
267
+
268
+ draft = null;
269
+
270
+ if (restore) {
271
+ last = found.html;
272
+ ge.setHtml(found.html);
273
+ ge.emit('after-restore-draft', { canvas: ge.canvas, html: found.html, savedAt: found.savedAt });
274
+ } else {
275
+ remove();
276
+ }
277
+
278
+ state = enabledAfterAsking ? 'enabled' : 'disabled';
279
+ if (state === 'enabled') { watch(); }
280
+ });
281
+ }
282
+
283
+ return {
284
+ // The first init, once it has finished: the canvas is ready to be read
285
+ onInit: function() {
286
+ if (started) { return; }
287
+ started = true;
288
+ Promise.resolve().then(start);
289
+ },
290
+
291
+ onSourceOpen: function() { sourceOpen = true; },
292
+ onSourceClose: function() { sourceOpen = false; },
293
+
294
+ onDestroy: function() {
295
+ if (state === 'enabled' && timer) { save('destroy'); }
296
+
297
+ unwatch();
298
+ observer = null;
299
+ window.removeEventListener('pagehide', onPageHide);
300
+ if (state !== 'unavailable') { liveKeys.delete(key); }
301
+ state = 'destroyed';
302
+ },
303
+
304
+ methods: {
305
+ enableAutosave: function() {
306
+ if (state === 'unavailable') { return false; }
307
+ if (state === 'asking') {
308
+ enabledAfterAsking = true;
309
+ return true;
310
+ }
311
+ if (state !== 'enabled') {
312
+ state = 'enabled';
313
+ if (base !== null) { watch(); }
314
+ }
315
+ return true;
316
+ },
317
+
318
+ disableAutosave: function() {
319
+ if (state === 'unavailable') { return false; }
320
+ if (state === 'asking') {
321
+ enabledAfterAsking = false;
322
+ return true;
323
+ }
324
+ unwatch();
325
+ state = 'disabled';
326
+ return true;
327
+ },
328
+
329
+ saveDraft: function() {
330
+ if (state === 'unavailable' || state === 'asking') { return false; }
331
+ return save('api');
332
+ },
333
+
334
+ getDraft: function() {
335
+ if (state === 'unavailable') { return null; }
336
+
337
+ var found = readDraft(false);
338
+ return found ? { html: found.html, savedAt: found.savedAt } : null;
339
+ },
340
+
341
+ clearDraft: function() {
342
+ if (state === 'unavailable') { return false; }
343
+
344
+ remove();
345
+ return true;
346
+ },
347
+ },
348
+ };
349
+ };
@@ -64,5 +64,11 @@ GridEditor.containers.card = function(ge) {
64
64
  unmark: function(container) {
65
65
  ge.unwrapLabels(container);
66
66
  },
67
+
68
+ // The same, on a copy of the canvas: unmark only touches markup
69
+ cleanMarkup: function(root) {
70
+ var definition = this;
71
+ dom.all(root, '[data-ge-container="card"]').forEach(function(container) { definition.unmark(container); });
72
+ },
67
73
  };
68
74
  };
@@ -157,6 +157,12 @@ GridEditor.texts.ckeditor = function(ge) {
157
157
  });
158
158
  },
159
159
 
160
+ // What destroy() puts back. Null while it is still starting
161
+ read: function(contentArea) {
162
+ var editor = editors.get(contentArea);
163
+ return editor ? editor.getData() : null;
164
+ },
165
+
160
166
  stop: function(contentAreas) {
161
167
  contentAreas.filter(function(contentArea) {
162
168
  return dom.hasClass(contentArea, 'active');
@@ -276,5 +276,8 @@ GridEditor.features.clipboard = function(ge) {
276
276
  document.removeEventListener(CHANGE, refresh);
277
277
  listening = false;
278
278
  },
279
+
280
+ // Nothing of its own on the canvas: the tools are in the drawers
281
+ cleanMarkup: function() {},
279
282
  };
280
283
  };
@@ -187,6 +187,13 @@ GridEditor.features['codemirror-inline'] = function(ge) {
187
187
  open.forEach(hide);
188
188
  },
189
189
 
190
+ // On a copy of the canvas: the editors are drawers, which the core
191
+ // takes off, and what they hid shows again. What is typed in one and
192
+ // not applied is not the block's yet
193
+ cleanMarkup: function(root) {
194
+ dom.all(root, '.ge-code-hidden').forEach(function(node) { dom.removeClass(node, 'ge-code-hidden'); });
195
+ },
196
+
190
197
  onInit: function() {
191
198
  open.slice().forEach(function(entry) {
192
199
  if (!dom.attached(entry.node)) {
@@ -139,8 +139,8 @@ GridEditor.features.elements = function(ge) {
139
139
  });
140
140
  }
141
141
 
142
- function unmarkElements() {
143
- dom.all(ge.canvas, '.ge-element').forEach(function(element) {
142
+ function unmarkElements(root) {
143
+ dom.all(root || ge.canvas, '.ge-element').forEach(function(element) {
144
144
  dom.removeClass(element, 'ge-element');
145
145
 
146
146
  // A host element that had no class of its own should not come
@@ -281,6 +281,7 @@ GridEditor.features.elements = function(ge) {
281
281
  },
282
282
 
283
283
  onInit: markElements,
284
- onDeinit: unmarkElements,
284
+ onDeinit: function() { unmarkElements(); },
285
+ cleanMarkup: unmarkElements,
285
286
  };
286
287
  };
@@ -146,5 +146,9 @@ GridEditor.utilities.gutters = function(ge) {
146
146
  listening = null;
147
147
  dom.all(ge.canvas, '.ge-gutters').forEach(function(row) { dom.removeClass(row, 'ge-gutters'); });
148
148
  },
149
+
150
+ cleanMarkup: function(root) {
151
+ dom.all(root, '.ge-gutters').forEach(function(row) { dom.removeClass(row, 'ge-gutters'); });
152
+ },
149
153
  };
150
154
  };
@@ -182,5 +182,7 @@ GridEditor.utilities['inline-style'] = function(ge) {
182
182
  each('onDeinit')();
183
183
  accordions = [];
184
184
  },
185
+
186
+ cleanMarkup: each('cleanMarkup'),
185
187
  };
186
188
  };
@@ -205,5 +205,9 @@ GridEditor.utilities.order = function(ge) {
205
205
  listening = null;
206
206
  dom.all(ge.canvas, '[data-ge-order]').forEach(function(col) { col.removeAttribute('data-ge-order'); });
207
207
  },
208
+
209
+ cleanMarkup: function(root) {
210
+ dom.all(root, '[data-ge-order]').forEach(function(col) { col.removeAttribute('data-ge-order'); });
211
+ },
208
212
  };
209
213
  };
@@ -25,8 +25,8 @@ GridEditor.containers.popup = function(ge) {
25
25
  }
26
26
 
27
27
  /** Whether a popup with this id is on the canvas. */
28
- function popupExists(id) {
29
- return dom.all(ge.canvas, '[data-ge-popup-id]').some(function(popup) {
28
+ function popupExists(id, root) {
29
+ return dom.all(root || ge.canvas, '[data-ge-popup-id]').some(function(popup) {
30
30
  return popupIdOf(popup) === id;
31
31
  });
32
32
  }
@@ -67,8 +67,10 @@ GridEditor.containers.popup = function(ge) {
67
67
  });
68
68
  }
69
69
 
70
- function writePopupTriggerAttributes() {
71
- dom.all(ge.canvas, '[data-ge-popup-target]').forEach(function(trigger) {
70
+ function writePopupTriggerAttributes(root) {
71
+ root = root || ge.canvas;
72
+
73
+ dom.all(root, '[data-ge-popup-target]').forEach(function(trigger) {
72
74
  var wanted = trigger.getAttribute('data-ge-popup-target');
73
75
 
74
76
  // The warning marking is editing furniture, whether or not
@@ -76,7 +78,7 @@ GridEditor.containers.popup = function(ge) {
76
78
  dom.removeClass(trigger, 'ge-popup-orphan');
77
79
  dom.dropEmptyClass(trigger);
78
80
 
79
- if (!popupExists(wanted)) { return; }
81
+ if (!popupExists(wanted, root)) { return; }
80
82
 
81
83
  trigger.setAttribute('data-bs-toggle', 'modal');
82
84
  trigger.setAttribute('data-bs-target', '#' + wanted);
@@ -91,7 +93,14 @@ GridEditor.containers.popup = function(ge) {
91
93
  // are looked at whenever the canvas is initialized rather than only
92
94
  // when a popup is touched
93
95
  onInit: wirePopupTriggers,
94
- onDeinit: writePopupTriggerAttributes,
96
+ onDeinit: function() { writePopupTriggerAttributes(); },
97
+
98
+ // What onDeinit and unmark do, on a copy of the canvas
99
+ cleanMarkup: function(root) {
100
+ var definition = this;
101
+ writePopupTriggerAttributes(root);
102
+ dom.all(root, '[data-ge-container="popup"]').forEach(function(container) { definition.unmark(container); });
103
+ },
95
104
 
96
105
  create: function(options) {
97
106
  var id = ge.containerId('popup');
@@ -149,8 +149,8 @@ GridEditor.features.sections = function(ge) {
149
149
  });
150
150
  }
151
151
 
152
- function unmark() {
153
- dom.children(ge.canvas, '.ge-section').forEach(function(section) {
152
+ function unmark(root) {
153
+ dom.children(root || ge.canvas, '.ge-section').forEach(function(section) {
154
154
  dom.removeClass(section, 'ge-section');
155
155
  });
156
156
  }
@@ -197,7 +197,8 @@ GridEditor.features.sections = function(ge) {
197
197
  }],
198
198
 
199
199
  onInit: mark,
200
- onDeinit: unmark,
200
+ onDeinit: function() { unmark(); },
201
+ cleanMarkup: unmark,
201
202
  };
202
203
 
203
204
  /** A section the toolbar just made, before init has marked it. */
@@ -120,6 +120,20 @@ GridEditor.texts.summernote = function(ge) {
120
120
  });
121
121
  },
122
122
 
123
+ // What destroy leaves in the content area: the editor's code
124
+ read: function(contentArea) {
125
+ var $ = window.jQuery;
126
+ if (!$ || !$.fn.summernote || !$(contentArea).data('summernote')) { return null; }
127
+
128
+ return $(contentArea).summernote('code');
129
+ },
130
+
131
+ // Its frame, which air mode puts right after the content area
132
+ cleanCopy: function(copy) {
133
+ var frame = copy.nextElementSibling;
134
+ if (frame && dom.hasClass(frame, 'note-editor')) { frame.remove(); }
135
+ },
136
+
123
137
  stop: function(contentAreas) {
124
138
  var $ = window.jQuery;
125
139
 
@@ -428,6 +428,12 @@ GridEditor.containers.tabs = function(ge) {
428
428
  ge.unwrapLabels(container);
429
429
  },
430
430
 
431
+ // The same, on a copy of the canvas: unmark only touches markup
432
+ cleanMarkup: function(root) {
433
+ var definition = this;
434
+ dom.all(root, '[data-ge-container="tabs"]').forEach(function(container) { definition.unmark(container); });
435
+ },
436
+
431
437
  panelSection: function(node, kind) {
432
438
  if (kind !== 'tabs' || !stripOf(node)) { return null; }
433
439
 
@@ -140,6 +140,14 @@ GridEditor.texts.tinymce = function(ge) {
140
140
  });
141
141
  },
142
142
 
143
+ // What remove() leaves in the content area: tinyMCE writes its
144
+ // content back as it removes an inline editor. Null while it is
145
+ // still starting, when there is nothing to ask
146
+ read: function(contentArea) {
147
+ var editor = editors.get(contentArea);
148
+ return editor ? editor.getContent() : null;
149
+ },
150
+
143
151
  stop: function(contentAreas) {
144
152
  contentAreas.filter(function(contentArea) {
145
153
  return dom.hasClass(contentArea, 'active');
@@ -347,6 +347,32 @@ if (!GridEditor.features.text) {
347
347
  clicks = null;
348
348
  },
349
349
 
350
+ // What closing the editors does, on a copy of the canvas: each
351
+ // open text holds what its editor says it holds, and the
352
+ // attributes the host gave it, as closeText leaves them. An
353
+ // editor that cannot say - no read, or not started yet - and the
354
+ // canvas has to leave editing to be read
355
+ cleanMarkup: function(root, liveOf) {
356
+ var blocks = dom.all(root, '.ge-content.ge-rte-active').filter(isOurs);
357
+
358
+ for (var i = 0; i < blocks.length; i++) {
359
+ var copy = blocks[i];
360
+ var block = liveOf(copy);
361
+ var text = TEXTS[copy.getAttribute('data-ge-content-type')];
362
+ var before = block && readBefore.get(block);
363
+ var html = before && text.read ? text.read(block) : null;
364
+
365
+ if (typeof html !== 'string') { return false; }
366
+
367
+ dom.setHtml(copy, html);
368
+ restoreAttributes(copy, before, readReady.get(block) || before, attributesOf(block));
369
+ // What the editor put beside the content area, its frame
370
+ if (text.cleanCopy) { text.cleanCopy(copy, block); }
371
+ }
372
+
373
+ return true;
374
+ },
375
+
350
376
  // The first time only: an undo says ready again, and by then the
351
377
  // host may have changed the content area itself
352
378
  onContentReady: function(area) {
@@ -31,17 +31,25 @@
31
31
  top: 0;
32
32
  }
33
33
 
34
+ /* The add buttons on the left are grey, so the blue is left to the
35
+ source, preview and layout buttons on the right */
36
+ .ge-addRowGroup .btn.btn-light,
37
+ .ge-addContainerGroup .btn.btn-light,
38
+ .ge-toolbar-more > .btn.btn-light {
39
+ background: #f4f4f4;
40
+ color: @rowTextColor;
41
+ border-color: #999;
42
+
43
+ &:hover,
44
+ &:active {
45
+ background: #E3E3E3;
46
+ }
47
+ }
48
+
34
49
  .ge-addRowGroup {
35
- .btn.btn-primary {
36
- background: #f4f4f4;
37
- color: @rowTextColor;
38
- border-color: #999;
50
+ .btn.btn-light {
39
51
  display: flex;
40
52
  align-items: center;
41
-
42
- &:hover {
43
- background: #E3E3E3;
44
- }
45
53
  }
46
54
 
47
55
  .ge-row-icon {