@themarioga/grid-editor 8.4.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 +51 -0
  2. package/README.md +105 -5
  3. package/dist/grideditor.bundle.min.js +3 -3
  4. package/dist/grideditor.css +15 -4
  5. package/dist/grideditor.d.ts +76 -4
  6. package/dist/grideditor.esm.js +212 -38
  7. package/dist/grideditor.js +212 -38
  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 +5 -0
  13. package/dist/locales/grideditor.es.js +5 -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 +30 -1
  29. package/dist/plugins/grideditor.ckeditor.js +30 -1
  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 +78 -4
  41. package/dist/plugins/grideditor.elements.js +78 -4
  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 +40 -1
  65. package/dist/plugins/grideditor.summernote.js +40 -1
  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 +32 -1
  73. package/dist/plugins/grideditor.tinymce.js +32 -1
  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 +16 -1
  78. package/docs/plugins.md +60 -7
  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 +248 -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 +5 -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 +98 -4
  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 +27 -1
  103. package/src/less/grideditor.less +16 -8
  104. package/types/grideditor.d.ts +76 -4
  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)) {
@@ -28,11 +28,95 @@ var NOT_ELEMENTS = '.row, .ge-content, .ge-text-block, [data-ge-container], .ge-
28
28
  GridEditor.features.elements = function(ge) {
29
29
 
30
30
  var warnedIntoText = false;
31
+ var types = validTypes();
31
32
 
32
33
  function elementsEnabled() {
33
34
  if (ge.settings.elements.enabled !== 'auto') { return !!ge.settings.elements.enabled; }
34
35
 
35
- return ge.settings.elements.auto || !!ge.canvas.querySelector(ge.settings.elements.selector);
36
+ // A host that offers elements on the toolbar uses them, even on a
37
+ // page that has none yet
38
+ return ge.settings.elements.auto || types.length > 0 ||
39
+ !!ge.canvas.querySelector(ge.settings.elements.selector);
40
+ }
41
+
42
+ /** The elements.types the toolbar can offer. One that can't be made is left out, with a word to the host. */
43
+ function validTypes() {
44
+ return (ge.settings.elements.types || []).filter(function(type, index) {
45
+ var named = type && typeof type.type === 'string' && type.type.trim() !== '';
46
+ var html = type && (typeof type.html === 'function' || (typeof type.html === 'string' && type.html.trim() !== ''));
47
+
48
+ if (!named || !html) {
49
+ ge.warn('elements.types[' + index + ']: a type needs a type name and its html, as a string or a ' +
50
+ 'function; it is left off the toolbar.');
51
+ }
52
+
53
+ return named && html;
54
+ });
55
+ }
56
+
57
+ /** A type's toolbar button. */
58
+ function typeItem(type) {
59
+ var mismatched = false;
60
+
61
+ return {
62
+ // Asked again whenever the toolbar is built, so setLocale
63
+ // translates a labelKey
64
+ label: function() { return labelOf(type); },
65
+ iconClass: type.iconClass,
66
+ group: type.group || 'elements',
67
+ kind: 'element',
68
+ inColumn: true,
69
+ create: function() {
70
+ var element = makeElement(type);
71
+
72
+ if (element && !mismatched && !ge.settings.elements.auto && !element.matches(ge.settings.elements.selector)) {
73
+ mismatched = true;
74
+ ge.warn('elements.types "' + type.type + '": what it makes does not match elements.selector "' +
75
+ ge.settings.elements.selector + '", so it will not be an element once the editor looks again.');
76
+ }
77
+
78
+ return element;
79
+ },
80
+ };
81
+ }
82
+
83
+ function labelOf(type) {
84
+ if (type.labelKey) { return ge.t(type.labelKey); }
85
+
86
+ return type.label || type.type;
87
+ }
88
+
89
+ /**
90
+ * What a type's button makes: the root of its html, marked as the host
91
+ * marks its own, or a div around it when it has no single root. Null,
92
+ * with a word to the host, when there is nothing to make.
93
+ */
94
+ function makeElement(type) {
95
+ var made;
96
+
97
+ try {
98
+ made = typeof type.html === 'function' ? type.html() : type.html;
99
+ } catch (error) {
100
+ ge.warn('elements.types "' + type.type + '": its html function threw (' + error.message + '); nothing was added.');
101
+ return null;
102
+ }
103
+
104
+ var nodes = typeof made === 'string' ? dom.parse(made) : (made && made.nodeType ? [made] : []);
105
+ // Whitespace between tags is not content
106
+ nodes = nodes.filter(function(node) { return node.nodeType !== 3 || node.textContent.trim() !== ''; });
107
+
108
+ if (!nodes.length) {
109
+ ge.warn('elements.types "' + type.type + '": its html came out empty; nothing was added.');
110
+ return null;
111
+ }
112
+
113
+ var element = nodes.length === 1 && nodes[0].nodeType === 1 ? nodes[0] : dom.element('div');
114
+ if (element !== nodes[0] || nodes.length > 1) { fill(element, nodes); }
115
+
116
+ element.setAttribute('data-ge-element', type.type);
117
+ if (!element.hasAttribute('data-ge-label')) { element.setAttribute('data-ge-label', labelOf(type)); }
118
+
119
+ return element;
36
120
  }
37
121
 
38
122
  /** Whether a child of a column is an element. */
@@ -55,8 +139,8 @@ GridEditor.features.elements = function(ge) {
55
139
  });
56
140
  }
57
141
 
58
- function unmarkElements() {
59
- dom.all(ge.canvas, '.ge-element').forEach(function(element) {
142
+ function unmarkElements(root) {
143
+ dom.all(root || ge.canvas, '.ge-element').forEach(function(element) {
60
144
  dom.removeClass(element, 'ge-element');
61
145
 
62
146
  // A host element that had no class of its own should not come
@@ -121,6 +205,11 @@ GridEditor.features.elements = function(ge) {
121
205
  return placed;
122
206
  }
123
207
 
208
+ /** enabled: false takes the toolbar's element buttons away too. */
209
+ function elementsOff() {
210
+ return ge.settings.elements.enabled === false;
211
+ }
212
+
124
213
  /** A place a caller named: an element, or the first one a selector matches. */
125
214
  function nodeFrom(node) {
126
215
  if (typeof node === 'string') { return document.querySelector(node); }
@@ -163,6 +252,10 @@ GridEditor.features.elements = function(ge) {
163
252
  createElement: apiCreateElement,
164
253
  },
165
254
 
255
+ // A button for each type the host offers, in the elements category
256
+ // unless the type names another
257
+ toolbar: elementsOff() ? [] : types.map(typeItem),
258
+
166
259
  /** A node this plugin marked is an element, whatever else it is. */
167
260
  kindOf: function(node) {
168
261
  return dom.hasClass(node, 'ge-element') ? 'element' : null;
@@ -188,6 +281,7 @@ GridEditor.features.elements = function(ge) {
188
281
  },
189
282
 
190
283
  onInit: markElements,
191
- onDeinit: unmarkElements,
284
+ onDeinit: function() { unmarkElements(); },
285
+ cleanMarkup: unmarkElements,
192
286
  };
193
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