@themarioga/grid-editor 3.1.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 (52) hide show
  1. package/AUTO_SAVE.md +30 -0
  2. package/BUILDING.md +68 -0
  3. package/CHANGELOG.md +362 -0
  4. package/LICENSE +23 -0
  5. package/README.md +521 -0
  6. package/UPGRADING.md +88 -0
  7. package/dist/grideditor.css +3875 -0
  8. package/dist/grideditor.min.css +2 -0
  9. package/dist/grideditor.min.css.map +1 -0
  10. package/dist/jquery.grideditor.js +2996 -0
  11. package/dist/jquery.grideditor.min.js +2 -0
  12. package/dist/jquery.grideditor.min.js.map +1 -0
  13. package/dist/locales/grideditor.es.js +74 -0
  14. package/dist/locales/grideditor.es.min.js +2 -0
  15. package/dist/locales/grideditor.es.min.js.map +1 -0
  16. package/dist/plugins/grideditor.accordion.js +185 -0
  17. package/dist/plugins/grideditor.accordion.min.js +2 -0
  18. package/dist/plugins/grideditor.accordion.min.js.map +1 -0
  19. package/dist/plugins/grideditor.elements.js +141 -0
  20. package/dist/plugins/grideditor.elements.min.js +2 -0
  21. package/dist/plugins/grideditor.elements.min.js.map +1 -0
  22. package/dist/plugins/grideditor.popup.js +143 -0
  23. package/dist/plugins/grideditor.popup.min.js +2 -0
  24. package/dist/plugins/grideditor.popup.min.js.map +1 -0
  25. package/dist/plugins/grideditor.tabs.js +140 -0
  26. package/dist/plugins/grideditor.tabs.min.js +2 -0
  27. package/dist/plugins/grideditor.tabs.min.js.map +1 -0
  28. package/docs/events.md +158 -0
  29. package/docs/locale-keys.md +140 -0
  30. package/docs/plugins.md +163 -0
  31. package/example/autosave.html +89 -0
  32. package/example/basic.html +93 -0
  33. package/example/breakpoints.html +94 -0
  34. package/example/ckeditor.html +104 -0
  35. package/example/containers.html +214 -0
  36. package/example/elements.html +147 -0
  37. package/example/index.html +119 -0
  38. package/example/locale.html +84 -0
  39. package/example/plugins.html +217 -0
  40. package/example/summernote.html +106 -0
  41. package/example/wrap_content.html +44 -0
  42. package/package.json +66 -0
  43. package/src/js/jquery.grideditor.ckeditor.js +77 -0
  44. package/src/js/jquery.grideditor.js +2711 -0
  45. package/src/js/jquery.grideditor.summernote.js +72 -0
  46. package/src/js/jquery.grideditor.tinymce.js +135 -0
  47. package/src/js/locales/grideditor.es.js +74 -0
  48. package/src/js/plugins/grideditor.accordion.js +185 -0
  49. package/src/js/plugins/grideditor.elements.js +141 -0
  50. package/src/js/plugins/grideditor.popup.js +143 -0
  51. package/src/js/plugins/grideditor.tabs.js +140 -0
  52. package/src/less/grideditor.less +594 -0
@@ -0,0 +1,2711 @@
1
+ /**
2
+ * Grid editor plugin.
3
+ *
4
+ * A fork of https://github.com/Friendly-Pixel/grid-editor by Simon Epskamp,
5
+ * maintained at https://github.com/themarioga/grid-editor.
6
+ */
7
+ (function( $ ){
8
+
9
+ /**
10
+ * Every method the plugin dispatches, and how to dispatch it.
11
+ *
12
+ * `value` marks a method that hands back something other than the jQuery set
13
+ * - html, a breakpoint key, a created node - so it runs against the first
14
+ * element of the set only and does not chain. `noInstance` is the answer for
15
+ * an element that carries no editor; every other method is a no-op returning
16
+ * the set. `unimplemented` registers a method a later phase fills in, so a
17
+ * host that calls it early gets told rather than ignored.
18
+ */
19
+ var METHODS = {
20
+ getHtml: { value: true, noInstance: function(element) { return element.html(); } },
21
+ init: {},
22
+ deinit: {},
23
+ reset: {},
24
+ destroy: {},
25
+ remove: {},
26
+ changeView: {},
27
+ getView: { value: true },
28
+ createRow: { value: true },
29
+ createColumn: { value: true },
30
+ createElement: { value: true },
31
+ createContainer: { value: true },
32
+ addTab: { value: true },
33
+ addAccordionItem: { value: true },
34
+ setLocale: {},
35
+ };
36
+
37
+ /**
38
+ * Where a create* call may put the node it just made. The first one given
39
+ * wins, and giving none leaves the node detached for the host to place.
40
+ */
41
+ var PLACEMENTS = ['appendTo', 'prependTo', 'insertAfter', 'insertBefore'];
42
+
43
+ /**
44
+ * Bootstrap 5's breakpoints, smallest first, which is the order the cascade
45
+ * runs in: a size written for a tier applies to every wider tier that does not
46
+ * override it. Every size and offset class grid-editor reads or writes comes
47
+ * from this table, so adding a tier is a row here and nothing else.
48
+ */
49
+ var BREAKPOINTS = [
50
+ { key: 'xs', colPrefix: 'col-', offsetPrefix: 'offset-', min: 0, preview: 400, labelKey: 'view.xs' },
51
+ { key: 'sm', colPrefix: 'col-sm-', offsetPrefix: 'offset-sm-', min: 576, preview: 576, labelKey: 'view.sm' },
52
+ { key: 'md', colPrefix: 'col-md-', offsetPrefix: 'offset-md-', min: 768, preview: 768, labelKey: 'view.md' },
53
+ { key: 'lg', colPrefix: 'col-lg-', offsetPrefix: 'offset-lg-', min: 992, preview: 992, labelKey: 'view.lg' },
54
+ { key: 'xl', colPrefix: 'col-xl-', offsetPrefix: 'offset-xl-', min: 1200, preview: 1200, labelKey: 'view.xl' },
55
+ { key: 'xxl', colPrefix: 'col-xxl-', offsetPrefix: 'offset-xxl-', min: 1400, preview: null, labelKey: 'view.xxl' },
56
+ ];
57
+
58
+ /**
59
+ * The view that edits every tier at once. It is the default, and the one most
60
+ * pages want: a layout that needs no per-device tuning is written once and
61
+ * lands on all six prefixes.
62
+ */
63
+ var ALL_VIEW = 'all';
64
+ var ALL_VIEW_LABEL_KEY = 'view.all';
65
+
66
+ /** Every view key the dropdown can offer, in the order it offers them. */
67
+ var VIEW_KEYS = [ALL_VIEW].concat(BREAKPOINTS.map(function(tier) { return tier.key; }));
68
+
69
+ /** What the three layout mode indexes of 2.x meant. */
70
+ var LEGACY_VIEW_INDEXES = ['lg', 'sm', 'xs'];
71
+
72
+ var MAX_COL_SIZE = 12;
73
+ var MAX_COL_OFFSET = 11;
74
+
75
+ function breakpoint(key) {
76
+ for (var i = 0; i < BREAKPOINTS.length; i++) {
77
+ if (BREAKPOINTS[i].key === key) { return BREAKPOINTS[i]; }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** The tiers a view writes to: one, or all six in the all view. */
83
+ function tiersFor(view) {
84
+ if (view === ALL_VIEW) { return BREAKPOINTS.slice(); }
85
+
86
+ var tier = breakpoint(view);
87
+ return tier ? [tier] : [];
88
+ }
89
+
90
+ function labelKeyFor(view) {
91
+ var tier = breakpoint(view);
92
+ return tier ? tier.labelKey : ALL_VIEW_LABEL_KEY;
93
+ }
94
+
95
+ /**
96
+ * Settings that are objects of grid-editor's own keys rather than something
97
+ * the host owns outright. A host naming one of their keys means "this one is
98
+ * different", not "forget the others", so these are filled in from their
99
+ * defaults instead of being replaced wholesale.
100
+ */
101
+ var NESTED_SETTINGS = {
102
+ add_column: {
103
+ size: 12, // What a click on the add column tool adds
104
+ picker: true, // Holding it offers the sizes instead
105
+ delay: 600, // How long to hold, in milliseconds
106
+ },
107
+ elements: {
108
+ enabled: 'auto', // 'auto' turns them on when the page has any
109
+ selector: '[data-ge-element]', // What the host marks an element with
110
+ auto: false, // Treat every child of a content area as an element
111
+ },
112
+ resize: {
113
+ enabled: true,
114
+ handles: 'e', // Which edges carry a handle, as jQuery UI names them
115
+ balance: 'next', // 'next' takes the units out of the following column
116
+ },
117
+ };
118
+
119
+ var warned = {};
120
+
121
+ /**
122
+ * Translate one key.
123
+ *
124
+ * Lookup order is locale_strings, then the selected locale, then English,
125
+ * then the key itself, so a missing string is a visible key and never an
126
+ * empty tooltip. `params` fills {name} placeholders.
127
+ *
128
+ * Exposed as $.fn.gridEditor.t for the editor integrations in the other
129
+ * source files, which are handed the settings and have no instance of their
130
+ * own.
131
+ */
132
+ function translate(settings, key, params) {
133
+ var locales = $.fn.gridEditor.locales;
134
+ var locale = locales[settings.locale] || {};
135
+ var overrides = settings.locale_strings || {};
136
+ var string = overrides[key];
137
+
138
+ if (string === undefined) { string = locale[key]; }
139
+ if (string === undefined) { string = locales.en[key]; }
140
+
141
+ if (string === undefined) {
142
+ warnOnce('locale:' + key, 'no string for "' + key + '" in any locale, showing the key');
143
+ string = key;
144
+ }
145
+
146
+ return string.replace(/\{(\w+)\}/g, function(placeholder, name) {
147
+ return params && params[name] !== undefined ? params[name] : placeholder;
148
+ });
149
+ }
150
+
151
+ function warn(message) {
152
+ if (window.console && window.console.warn) {
153
+ window.console.warn('grid-editor: ' + message);
154
+ }
155
+ }
156
+
157
+ /** Warn about something the host can only usefully be told about once. */
158
+ function warnOnce(key, message) {
159
+ if (warned[key]) { return; }
160
+ warned[key] = true;
161
+ warn(message);
162
+ }
163
+
164
+ /**
165
+ * Run a string method against a set of elements.
166
+ *
167
+ * An element with no editor on it is not an error: the method is a no-op and
168
+ * the set comes back for chaining, so host code does not have to check first.
169
+ * `getHtml` is the exception, because reading an element's html makes sense
170
+ * whether or not it is being edited.
171
+ */
172
+ function dispatch(set, name, args) {
173
+ var descriptor = METHODS[name];
174
+
175
+ if (!descriptor) {
176
+ warnOnce('method:' + name, 'unknown method "' + name + '"');
177
+ return set;
178
+ }
179
+
180
+ if (descriptor.value) {
181
+ var element = set.first();
182
+ if (!element.length) { return null; }
183
+
184
+ var instance = element.data('grideditor');
185
+ if (!instance) {
186
+ return descriptor.noInstance ? descriptor.noInstance(element) : null;
187
+ }
188
+
189
+ return instance[name].apply(instance, args);
190
+ }
191
+
192
+ set.each(function() {
193
+ var found = $(this).data('grideditor');
194
+ if (found) { found[name].apply(found, args); }
195
+ });
196
+
197
+ return set;
198
+ }
199
+
200
+ $.fn.gridEditor = function( optionsOrMethod ) {
201
+
202
+ var self = this;
203
+
204
+ /** Methods **/
205
+
206
+ if (typeof optionsOrMethod == 'string') {
207
+ return dispatch(self, optionsOrMethod, Array.prototype.slice.call(arguments, 1));
208
+ }
209
+
210
+ /** Initialize plugin */
211
+
212
+ self.each(function(baseIndex, baseElem) {
213
+ baseElem = $(baseElem);
214
+
215
+ var settings = $.extend({
216
+ 'new_row_layouts' : [ // Column layouts for add row buttons
217
+ [12],
218
+ [6, 6],
219
+ [4, 4, 4],
220
+ [3, 3, 3, 3],
221
+ [2, 2, 2, 2, 2, 2],
222
+ [2, 8, 2],
223
+ [4, 8],
224
+ [8, 4]
225
+ ],
226
+ 'row_classes' : [], // Preset class toggles, on top of the classes field
227
+ 'col_classes' : [],
228
+ 'col_tools' : [], /* Example:
229
+ [ {
230
+ title: 'Set background image',
231
+ iconClass: 'glyphicon-picture',
232
+ on: { click: function() {} }
233
+ } ]
234
+ */
235
+ 'row_tools' : [],
236
+ 'drag_handle' : 'tool', // 'tool' for the move tool, 'drawer' for the whole drawer
237
+ 'toolbar_drag' : 'auto', // Drag the toolbar's buttons onto the canvas. 'auto' follows drag_handle
238
+ 'element_tools' : [], // Host tools on element drawers, same shape as row_tools
239
+ 'element_classes' : [], // Preset class toggles on an element's settings panel
240
+ 'container_classes' : [], // The same, on a container's panel
241
+ 'pane_classes' : [], // And on a tab's or an accordion item's
242
+ 'container_tools' : [], // Host tools on container drawers
243
+ 'tab_tools' : [], // Host tools on tab drawers
244
+ 'accordion_tools' : [], // Host tools on accordion item drawers
245
+ 'plugins' : null, // Container plugins to use; null means every one loaded
246
+ 'elements' : NESTED_SETTINGS.elements, // Element level controls, below the column
247
+ 'custom_filter' : '',
248
+ 'content_types' : ['tinymce'],
249
+ 'valid_col_sizes' : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
250
+ 'valid_col_offsets' : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
251
+ 'add_column' : NESTED_SETTINGS.add_column, // The add column tool
252
+ 'layout_modes' : VIEW_KEYS.slice(), // Which views the dropdown offers
253
+ 'default_view' : ALL_VIEW,
254
+ 'resize' : NESTED_SETTINGS.resize, // Resizing a column by dragging its edge
255
+ 'resizable_options' : {}, // Merged into every jQuery UI resizable
256
+ 'source_textarea' : '',
257
+ 'locale' : 'en', // Code of a locale in $.fn.gridEditor.locales
258
+ 'locale_strings' : {}, // Overrides for individual keys
259
+ 'callbacks' : {}, // before_*/after_* functions, the events by another route
260
+ 'confirm_delete' : true, // Ask before deleting a row or a column
261
+ 'sortable_options' : {} // Merged into every jQuery UI sortable
262
+ }, optionsOrMethod);
263
+
264
+ // Merged rather than replaced, so `elements: { auto: true }` keeps the
265
+ // default selector instead of losing it
266
+ $.each(NESTED_SETTINGS, function(name, defaults) {
267
+ settings[name] = $.extend({}, defaults, settings[name]);
268
+ });
269
+
270
+
271
+ // Elems
272
+ var canvas,
273
+ mainControls,
274
+ wrapper, // controls wrapper
275
+ addRowGroup,
276
+ addContainerGroup,
277
+ layoutDropdown,
278
+ htmlTextArea
279
+ ;
280
+ var curView = settings.default_view; // Breakpoint key, or 'all'
281
+ var confirmDialog = null; // The delete confirmation, built when first needed
282
+ var sizePicker = null; // The open column size picker, if there is one
283
+ var dropMarker = null; // The line showing where a dragged toolbar button would land
284
+ var warnedHere = {}; // Deprecations are worth saying once per instance, not once per call
285
+
286
+ // Before anything else, because the instance handle hands the canvas
287
+ // to hosts and the rest of setup() runs at the end of this function
288
+ canvas = baseElem.addClass('ge-canvas');
289
+
290
+ function warnOnceHere(key, message) {
291
+ if (warnedHere[key]) { return; }
292
+ warnedHere[key] = true;
293
+ warn(message);
294
+ }
295
+
296
+ /** This instance's strings, in the locale its settings asked for. */
297
+ function t(key, params) {
298
+ return translate(settings, key, params);
299
+ }
300
+
301
+ /**
302
+ * Swap language at runtime. The controls carry their strings in
303
+ * attributes, so they are rebuilt rather than patched.
304
+ */
305
+ function setLocale(code) {
306
+ settings.locale = code;
307
+ handle.settings = settingsCopy();
308
+
309
+ removeConfirmModal();
310
+ mainControls.remove();
311
+ createMainControls();
312
+ reset();
313
+ }
314
+
315
+ var operationDepth = 0; // Operations running right now
316
+ var deferredWork = []; // What handlers asked for while one was running
317
+
318
+ /**
319
+ * The payload every notification carries. Built here, and only here,
320
+ * so no caller assembles one by hand and gets a field wrong.
321
+ *
322
+ * `parent` is where the node is going, or where it is coming from on a
323
+ * delete, which is not the same as node.parent() while the node is
324
+ * still detached - so an add passes it in.
325
+ */
326
+ function payloadFor(kind, node, extra) {
327
+ return $.extend({
328
+ kind: kind,
329
+ node: node,
330
+ parent: node.parent(),
331
+ canvas: canvas,
332
+ breakpoint: getView(),
333
+ source: 'api',
334
+ }, extra || {});
335
+ }
336
+
337
+ /**
338
+ * Deliver one notification twice: as a jQuery event on the canvas -
339
+ * the specific name first, then the generic one - and as the matching
340
+ * settings.callbacks entries. Everything is delivered whatever the
341
+ * first listener says, and the answer is whether any of them canceled,
342
+ * which only means something for a before-* notification.
343
+ */
344
+ function emit(name, payload) {
345
+ var names = [name];
346
+ var generic = name.replace(/^(before|after)-add-.+$/, '$1-add');
347
+ if (generic !== name) { names.push(generic); }
348
+
349
+ var canceled = false;
350
+
351
+ names.forEach(function(eventName) {
352
+ var event = $.Event('grideditor:' + eventName);
353
+ canvas.trigger(event, [payload]);
354
+ if (event.isDefaultPrevented()) { canceled = true; }
355
+ });
356
+
357
+ names.forEach(function(eventName) {
358
+ var callback = settings.callbacks[eventName.replace(/-/g, '_')];
359
+ if (typeof callback == 'function' && callback(payload) === false) {
360
+ canceled = true;
361
+ }
362
+ });
363
+
364
+ return !canceled;
365
+ }
366
+
367
+ /**
368
+ * One operation, from its before-* notification to its after-* one.
369
+ *
370
+ * Anything a handler asks the editor to do while the operation runs is
371
+ * queued and played back once it finishes, so a handler cannot reset
372
+ * the canvas out from under the operation that called it.
373
+ */
374
+ function operate(body) {
375
+ operationDepth++;
376
+ try {
377
+ return body();
378
+ } finally {
379
+ operationDepth--;
380
+ if (operationDepth === 0) {
381
+ while (deferredWork.length) {
382
+ deferredWork.shift()();
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ /** Run `work` now, or after the running operation if there is one. */
389
+ function defer(work) {
390
+ if (operationDepth === 0) {
391
+ work();
392
+ return;
393
+ }
394
+
395
+ deferredWork.push(work);
396
+ }
397
+
398
+ /**
399
+ * Insert a node: ask, insert, bring the canvas up to date so the new
400
+ * markup has its controls, then announce it. Hands back the node, or
401
+ * null when a handler canceled.
402
+ *
403
+ * The update is init() rather than reset(): a reset deinitializes
404
+ * every rich text editor on the canvas, and adding a row somewhere
405
+ * else is no reason to close the editor the user is typing in.
406
+ */
407
+ function addNode(kind, node, insert, extra) {
408
+ var name = addEventName(kind);
409
+
410
+ return operate(function() {
411
+ var payload = payloadFor(kind, node, extra);
412
+
413
+ if (!emit('before-add-' + name, payload)) { return null; }
414
+
415
+ insert();
416
+ init();
417
+ emit('after-add-' + name, payload);
418
+
419
+ return node;
420
+ });
421
+ }
422
+
423
+ /**
424
+ * Every container type shares one pair of add events, with the
425
+ * payload's kind saying which type it was: a host that cares about
426
+ * containers binds one name, not three.
427
+ */
428
+ function addEventName(kind) {
429
+ return $.fn.gridEditor.containers[kind] ? 'container' : kind;
430
+ }
431
+
432
+ /**
433
+ * Ask the user, in Bootstrap's own modal.
434
+ *
435
+ * Bootstrap is already a dependency of an editor for Bootstrap's grid,
436
+ * and window.confirm cannot be styled, cannot be translated by us and
437
+ * blocks the page while it is up. The modal lives outside the canvas,
438
+ * so it is never part of what getHtml returns.
439
+ *
440
+ * A page that loaded Bootstrap's css but not its javascript still gets
441
+ * asked - by the browser, as before.
442
+ */
443
+ function askToDelete(message, whenConfirmed) {
444
+ if (!settings.confirm_delete) {
445
+ whenConfirmed();
446
+ return;
447
+ }
448
+
449
+ if (!window.bootstrap || !window.bootstrap.Modal) {
450
+ if (window.confirm(message)) { whenConfirmed(); }
451
+ return;
452
+ }
453
+
454
+ var modal = confirmModal();
455
+ var confirmed = false;
456
+
457
+ modal.find('.ge-confirm-message').text(message);
458
+ modal.find('.ge-confirm-ok').off('click').on('click', function() {
459
+ confirmed = true;
460
+ window.bootstrap.Modal.getInstance(modal[0]).hide();
461
+ });
462
+
463
+ modal.off('hidden.bs.modal').on('hidden.bs.modal', function() {
464
+ // After the modal is out of the way, so the backdrop is not
465
+ // sitting over the animation the delete runs
466
+ if (confirmed) { whenConfirmed(); }
467
+ });
468
+
469
+ modal.off('shown.bs.modal').on('shown.bs.modal', function() {
470
+ modal.find('.ge-confirm-ok').trigger('focus');
471
+ });
472
+
473
+ window.bootstrap.Modal.getOrCreateInstance(modal[0]).show();
474
+ }
475
+
476
+ /** Built once per instance, and taken away again by destroy(). */
477
+ function confirmModal() {
478
+ if (confirmDialog) { return confirmDialog; }
479
+
480
+ confirmDialog = $(
481
+ '<div class="modal fade ge-confirm" tabindex="-1" aria-hidden="true">' +
482
+ '<div class="modal-dialog modal-dialog-centered">' +
483
+ '<div class="modal-content">' +
484
+ '<div class="modal-header">' +
485
+ '<h5 class="modal-title"></h5>' +
486
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
487
+ '</div>' +
488
+ '<div class="modal-body"><p class="ge-confirm-message"></p></div>' +
489
+ '<div class="modal-footer">' +
490
+ '<button type="button" class="btn btn-secondary ge-confirm-cancel" data-bs-dismiss="modal"></button>' +
491
+ '<button type="button" class="btn btn-danger ge-confirm-ok"></button>' +
492
+ '</div>' +
493
+ '</div>' +
494
+ '</div>' +
495
+ '</div>'
496
+ ).appendTo('body');
497
+
498
+ confirmDialog.find('.modal-title').text(t('confirm.title'));
499
+ confirmDialog.find('.btn-close').attr('aria-label', t('confirm.cancel'));
500
+ confirmDialog.find('.ge-confirm-cancel').text(t('confirm.cancel'));
501
+ confirmDialog.find('.ge-confirm-ok').text(t('confirm.ok'));
502
+
503
+ return confirmDialog;
504
+ }
505
+
506
+ /** The confirm modal is rebuilt in the new language on setLocale. */
507
+ function removeConfirmModal() {
508
+ if (!confirmDialog) { return; }
509
+
510
+ if (window.bootstrap && window.bootstrap.Modal) {
511
+ var instance = window.bootstrap.Modal.getInstance(confirmDialog[0]);
512
+ if (instance) { instance.dispose(); }
513
+ }
514
+
515
+ confirmDialog.remove();
516
+ confirmDialog = null;
517
+ }
518
+
519
+ /**
520
+ * Remove a node: ask the host, then the user, then remove it, update
521
+ * the canvas and announce it once the animation has finished.
522
+ *
523
+ * The host's handler goes first on purpose. A host that cancels
524
+ * before-delete to ask in its own way does not want the built-in
525
+ * question to have been asked already.
526
+ */
527
+ function deleteNode(kind, node, message, animate) {
528
+ operate(function() {
529
+ var payload = payloadFor(kind, node, { source: 'tool' });
530
+
531
+ if (!emit('before-delete', payload)) { return; }
532
+
533
+ askToDelete(message, function() {
534
+ operate(function() {
535
+ animate(function() {
536
+ node.remove();
537
+ operate(function() {
538
+ init();
539
+ emit('after-delete', payload);
540
+ });
541
+ });
542
+ });
543
+ });
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Resize a column through the events, writing the tiers the current
549
+ * view covers: one in a per-breakpoint view, all six in the all view.
550
+ *
551
+ * The budget is checked per tier and the whole change is refused if
552
+ * any tier has no room, so a resize never half lands.
553
+ */
554
+ /**
555
+ * What resizing this column to `size` would write, or null when the
556
+ * budget refuses it or there is nothing to change.
557
+ *
558
+ * Refused rather than quietly clamped: the offset is something the
559
+ * user set, and a tool that rewrites it is a tool that lies. The plan
560
+ * covers every tier the view writes, so a resize never half lands.
561
+ */
562
+ function planSize(col, size) {
563
+ var tiers = tiersFor(curView);
564
+ var wanted = tiers.map(function(tier) {
565
+ return clamp({ size: size, offset: getEffectiveOffset(col, tier) || 0 });
566
+ });
567
+
568
+ if (wanted.some(function(request) { return request.refused; })) { return null; }
569
+
570
+ var unchanged = tiers.every(function(tier, i) {
571
+ return getSize(col, tier) === wanted[i].size;
572
+ });
573
+ if (unchanged) { return null; }
574
+
575
+ return { tiers: tiers, sizes: wanted, size: wanted[0].size };
576
+ }
577
+
578
+ function writeSize(col, plan) {
579
+ plan.tiers.forEach(function(tier, i) { setSize(col, tier, plan.sizes[i].size); });
580
+ stripPixelWidths(col);
581
+ }
582
+
583
+ /** Resize a column from a tool, announcing it either side. */
584
+ function resizeColumn(col, size, source) {
585
+ var from = currentSize(col);
586
+ var plan = planSize(col, size);
587
+
588
+ if (!plan) { return false; }
589
+
590
+ return operate(function() {
591
+ var payload = payloadFor('column', col, { source: source, from: from, to: plan.size });
592
+
593
+ if (!emit('before-resize', payload)) { return false; }
594
+
595
+ writeSize(col, plan);
596
+ emit('after-resize', payload);
597
+
598
+ return true;
599
+ });
600
+ }
601
+
602
+ /**
603
+ * Indent a column, shrinking it when the budget needs it: the offset
604
+ * is what the user asked for, so it is the one that gets its way.
605
+ */
606
+ function indentColumn(col, offset, source) {
607
+ var tiers = tiersFor(curView);
608
+ var from = currentOffset(col);
609
+
610
+ offset = Math.min(Math.max(offset, 0), MAX_COL_OFFSET);
611
+
612
+ var unchanged = tiers.every(function(tier) {
613
+ return (getOffset(col, tier) || 0) === offset;
614
+ });
615
+ if (unchanged) { return false; }
616
+
617
+ return operate(function() {
618
+ var payload = payloadFor('column', col, {
619
+ source: source,
620
+ from: from,
621
+ to: offset,
622
+ });
623
+
624
+ if (!emit('before-indent', payload)) { return false; }
625
+
626
+ tiers.forEach(function(tier) {
627
+ var was = getEffectiveSize(col, tier);
628
+ var wanted = clamp({ size: was, offset: offset, leading: 'offset' });
629
+
630
+ setOffset(col, tier, wanted.offset);
631
+
632
+ // Only where the budget actually forced the column to give
633
+ // way; otherwise an indent would write size classes for
634
+ // tiers nobody asked it to touch
635
+ if (wanted.size !== null && wanted.size !== was) {
636
+ setSize(col, tier, wanted.size);
637
+ }
638
+ });
639
+
640
+ emit('after-indent', payload);
641
+
642
+ return true;
643
+ });
644
+ }
645
+
646
+ /**
647
+ * Where a node sits, for the from/to of a move. Tool drawers are not
648
+ * counted, so the index is the one a host would recognize.
649
+ */
650
+ function positionOf(node) {
651
+ var parent = node.parent();
652
+
653
+ return {
654
+ parent: parent,
655
+ index: parent.children().not('.ge-tools-drawer').index(node),
656
+ };
657
+ }
658
+
659
+ function kindOf(node) {
660
+ var fromPlugin = null;
661
+
662
+ $.each(FEATURES, function(name, feature) {
663
+ if (!fromPlugin && feature.kindOf) { fromPlugin = feature.kindOf(node); }
664
+ });
665
+ if (fromPlugin) { return fromPlugin; }
666
+
667
+ if (node.attr('data-ge-container')) { return node.attr('data-ge-container'); }
668
+ if (node.hasClass('ge-tab')) { return 'tab'; }
669
+ if (node.hasClass('ge-accordion-item')) { return 'accordion-item'; }
670
+ if (node.hasClass('row')) { return 'row'; }
671
+ if (node.hasClass('column')) { return 'column'; }
672
+ if (node.hasClass('ge-element')) { return 'element'; }
673
+ if (node.hasClass('ge-content')) { return 'content'; }
674
+ return 'node';
675
+ }
676
+
677
+ // Copy html to sourceElement if a source textarea is given
678
+ if (settings.source_textarea) {
679
+ var sourceHtml = $(settings.source_textarea).val();
680
+ if (sourceHtml.length > 0 && $('<div>' + sourceHtml + '</div>').find('.row').addBack('.row').length == 0) {
681
+ var sourceRow = createRow();
682
+ var sourceColumn = createColumn(12).appendTo(sourceRow);
683
+ sourceColumn.find('.ge-content').html(sourceHtml);
684
+ sourceHtml = sourceColumn.html();
685
+ }
686
+ baseElem.html(sourceHtml);
687
+ }
688
+
689
+ // Wrap content if it is non-bootstrap
690
+ if (baseElem.children().length && !baseElem.find('div.row').length) {
691
+ var children = baseElem.children();
692
+ var newRow = $('<div class="row"><div class="col-lg-12"/></div>').appendTo(baseElem);
693
+ newRow.find('.col-lg-12').append(children);
694
+ }
695
+
696
+ // setup() and init() run at the end of this function, once every
697
+ // table and helper below has been assigned: the toolbar is built from
698
+ // the container registry, and a var declared later is not there yet.
699
+
700
+ function setup() {
701
+ htmlTextArea = $('<textarea class="ge-html-output"/>').insertBefore(canvas);
702
+
703
+ createMainControls();
704
+
705
+ // Make controls fixed on scroll
706
+ $(window).on('scroll', onScroll);
707
+
708
+ /* Init RTE on click */
709
+ canvas.on('click', '.ge-content', initRTE);
710
+
711
+ /* A trigger is often a link, and a link still navigates even
712
+ with its Bootstrap attributes suspended */
713
+ canvas.on('click', '.ge-popup-trigger, [data-ge-popup-target]', function(e) {
714
+ if (canvas.hasClass('ge-editing')) { e.preventDefault(); }
715
+ });
716
+
717
+ // A rich text editor rewrites the content area as it takes over,
718
+ // which costs the element drawers inside it. The integrations say
719
+ // when their editor is ready, and the drawers go back in.
720
+ canvas.on('ge-rte-ready', '.ge-content', function() {
721
+ plugins('onContentReady', $(this));
722
+ });
723
+ }
724
+
725
+ /**
726
+ * The toolbar above the canvas. Separate from setup() because every
727
+ * string in it comes from the locale, so setLocale() rebuilds it.
728
+ */
729
+ function createMainControls() {
730
+ mainControls = $('<div class="ge-mainControls" />').insertBefore(htmlTextArea);
731
+ wrapper = $('<div class="ge-wrapper ge-top" />').appendTo(mainControls);
732
+
733
+ // Add row
734
+ addRowGroup = $('<div class="ge-addRowGroup btn-group" />').appendTo(wrapper);
735
+ addContainerGroup = $('<div class="ge-addContainerGroup btn-group" />');
736
+ $.each(settings.new_row_layouts, function(j, layout) {
737
+ var btn = $('<a class="btn btn-sm btn-primary" />')
738
+ .attr('title', t('row.add', { layout: layout.join('-') }))
739
+ // What this button makes, in the markup rather than in
740
+ // jQuery data: a drag works on a clone of it
741
+ .attr('data-ge-toolbar', 'row')
742
+ .attr('data-ge-layout', layout.join(','))
743
+ .on('click', function() {
744
+ var row = createRow();
745
+ layout.forEach(function(i) {
746
+ createColumn(i).appendTo(row);
747
+ });
748
+
749
+ var added = addNode('row', row, function() {
750
+ row.appendTo(canvas);
751
+ }, { parent: canvas, source: 'tool' });
752
+
753
+ if (added && row[0].scrollIntoView) {
754
+ row[0].scrollIntoView({behavior: 'smooth'});
755
+ }
756
+ })
757
+ .appendTo(addRowGroup)
758
+ ;
759
+
760
+ btn.append('<i class="bi bi-plus"></i>');
761
+
762
+ var layoutName = layout.join(' - ');
763
+ var icon = '<div class="row ge-row-icon">';
764
+ layout.forEach(function(i) {
765
+ icon += '<div class="column col-' + i + '"/>';
766
+ });
767
+ icon += '</div>';
768
+ btn.append(icon);
769
+ });
770
+
771
+ addContainerGroup.appendTo(wrapper);
772
+
773
+ // A container starts in a row of its own, the way the add row
774
+ // buttons next to these ones do
775
+ $.each(CONTAINERS, function(type, definition) {
776
+ $('<a class="btn btn-sm btn-primary ge-add-container" />')
777
+ .attr('title', t(definition.labelKey))
778
+ .attr('data-ge-toolbar', 'container')
779
+ .attr('data-ge-container-type', type)
780
+ .append('<i class="bi bi-plus"></i>')
781
+ .append($('<span />').text(t(definition.labelKey)))
782
+ .on('click', function() {
783
+ var row = createRow();
784
+ var column = createColumn(MAX_COL_SIZE).appendTo(row);
785
+ var container = definition.create({});
786
+
787
+ column.find('> .ge-content').replaceWith(container);
788
+
789
+ addNode(type, container, function() {
790
+ row.appendTo(canvas);
791
+ }, { parent: canvas, source: 'tool' });
792
+ })
793
+ .appendTo(addContainerGroup)
794
+ ;
795
+ });
796
+
797
+ // Buttons on right
798
+ layoutDropdown = $('<div class="dropdown pull-right ge-layout-mode">' +
799
+ '<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown"></button>' +
800
+ '<div class="dropdown-menu" role="menu"></div>' +
801
+ '</div>')
802
+ .on('click', 'a', function() {
803
+ // Through changeView, so the dropdown and the method are
804
+ // one path rather than two that have to agree
805
+ changeView($(this).attr('data-ge-view'));
806
+ })
807
+ .appendTo(wrapper)
808
+ ;
809
+ settings.layout_modes.forEach(function(view) {
810
+ $('<a class="dropdown-item" />')
811
+ .attr('data-ge-view', view)
812
+ .attr('title', t(labelKeyFor(view)))
813
+ .text(t(labelKeyFor(view)))
814
+ .appendTo(layoutDropdown.find('.dropdown-menu'))
815
+ ;
816
+ });
817
+ layoutDropdown.find('button').text(t(labelKeyFor(curView)));
818
+
819
+ var btnGroup = $('<div class="btn-group pull-right"/>')
820
+ .appendTo(wrapper)
821
+ ;
822
+ var htmlButton = $('<button type="button" class="btn btn-sm btn-primary gm-edit-mode"><i class="bi bi-code-slash"></i></button>')
823
+ .attr('title', t('tool.edit_source'))
824
+ .on('click', function() {
825
+ if (htmlButton.hasClass('active')) {
826
+ canvas.empty().html(htmlTextArea.val()).show();
827
+ init();
828
+ htmlTextArea.hide();
829
+ } else {
830
+ deinit();
831
+ htmlTextArea
832
+ .height(0.8 * $(window).height())
833
+ .val(canvas.html())
834
+ .show()
835
+ ;
836
+ canvas.hide();
837
+ }
838
+
839
+ htmlButton.toggleClass('active btn-danger');
840
+ })
841
+ .appendTo(btnGroup)
842
+ ;
843
+ var previewButton = $('<button type="button" class="btn btn-sm btn-primary gm-preview"><i class="bi bi-eye-fill"></i></button>')
844
+ .attr('title', t('tool.preview'))
845
+ .on('mouseenter', function() {
846
+ canvas.removeClass('ge-editing');
847
+ })
848
+ .on('click', function() {
849
+ previewButton.toggleClass('active btn-danger').trigger('mouseleave');
850
+ })
851
+ .on('mouseleave', function() {
852
+ if (!previewButton.hasClass('active')) {
853
+ canvas.addClass('ge-editing');
854
+ }
855
+ })
856
+ .appendTo(btnGroup)
857
+ ;
858
+
859
+ makeToolbarDraggable();
860
+ }
861
+
862
+ /**
863
+ * The toolbar's buttons as a palette: drag one onto the canvas and
864
+ * what it makes is created where it lands, rather than at the end.
865
+ *
866
+ * On by default in the mode where everything else is dragged by its
867
+ * body rather than by a handle, since that is the same idea applied to
868
+ * the toolbar; toolbar_drag: true or false decides it outright.
869
+ */
870
+ function toolbarDrags() {
871
+ if (settings.toolbar_drag === 'auto') { return settings.drag_handle === 'drawer'; }
872
+
873
+ return !!settings.toolbar_drag;
874
+ }
875
+
876
+ function makeToolbarDraggable() {
877
+ if (!toolbarDrags()) { return; }
878
+
879
+ mainControls.find('[data-ge-toolbar]').draggable({
880
+ helper: function() { return $(this).clone().addClass('ge-toolbar-helper'); },
881
+ appendTo: 'body',
882
+ zIndex: 1000,
883
+ cursorAt: { top: 14, left: 14 },
884
+
885
+ start: function() { canvas.addClass('ge-dropping'); },
886
+ drag: function(e) { showDropMarker(e.pageX, e.pageY); },
887
+
888
+ stop: function(e) {
889
+ var where = dropPlaceAt(e.pageX, e.pageY);
890
+
891
+ canvas.removeClass('ge-dropping');
892
+ hideDropMarker();
893
+
894
+ if (where) { insertFromToolbar($(this), where); }
895
+ },
896
+ });
897
+ }
898
+
899
+ /**
900
+ * Where a drop at this point would put things: which region it lands
901
+ * in, and which of that region's children it goes before.
902
+ *
903
+ * Worked out from the pointer rather than handed to a sortable. The
904
+ * canvas is a tree of regions that connected sortables fight over -
905
+ * a column grows as a placeholder is put in it, until it covers the
906
+ * pointer wherever the pointer goes - and a new block has one
907
+ * question to answer, which is where it lands.
908
+ */
909
+ function dropPlaceAt(pageX, pageY) {
910
+ var x = pageX - window.scrollX;
911
+ var y = pageY - window.scrollY;
912
+ var under = document.elementFromPoint(x, y);
913
+
914
+ if (!under) { return null; }
915
+
916
+ var region = $(under).closest('.column, .ge-canvas');
917
+ if (!region.length || (region[0] !== canvas[0] && !canvas[0].contains(region[0]))) {
918
+ return null;
919
+ }
920
+
921
+ var before = null;
922
+
923
+ region.children('.row, .ge-content, [data-ge-container]').each(function() {
924
+ if (before) { return; }
925
+
926
+ var box = this.getBoundingClientRect();
927
+ if (y < box.top + box.height / 2) { before = $(this); }
928
+ });
929
+
930
+ return { region: region, before: before };
931
+ }
932
+
933
+ /** A line where the block would go, following the pointer. */
934
+ function showDropMarker(pageX, pageY) {
935
+ var where = dropPlaceAt(pageX, pageY);
936
+
937
+ if (!where) { return hideDropMarker(); }
938
+
939
+ if (!dropMarker) { dropMarker = $('<div class="ge-drop-marker" />'); }
940
+
941
+ if (where.before) {
942
+ dropMarker.insertBefore(where.before);
943
+ } else {
944
+ dropMarker.appendTo(where.region);
945
+ }
946
+
947
+ return undefined;
948
+ }
949
+
950
+ function hideDropMarker() {
951
+ if (dropMarker) { dropMarker.remove(); }
952
+ }
953
+
954
+ /**
955
+ * The row or container a toolbar button stands for, made and put where
956
+ * the pointer left it.
957
+ */
958
+ function insertFromToolbar(button, where) {
959
+ var container = button.attr('data-ge-toolbar') === 'container';
960
+ var type = button.attr('data-ge-container-type');
961
+ var made = container
962
+ ? CONTAINERS[type].create({})
963
+ : rowFromLayout(button.attr('data-ge-layout'));
964
+
965
+ // A container belongs in a column: dropped straight onto the
966
+ // canvas it brings a row and a column of its own
967
+ var placed = made;
968
+ if (container && !where.region.is('.column')) {
969
+ placed = createRow();
970
+ createColumn(MAX_COL_SIZE).appendTo(placed)
971
+ .find('> .ge-content').replaceWith(made);
972
+ }
973
+
974
+ return addNode(container ? type : 'row', made, function() {
975
+ if (where.before) {
976
+ placed.insertBefore(where.before);
977
+ } else {
978
+ placed.appendTo(where.region);
979
+ }
980
+ }, { parent: where.region, source: 'dragdrop' });
981
+ }
982
+
983
+ function rowFromLayout(layout) {
984
+ var row = createRow();
985
+
986
+ (layout || '').split(',').forEach(function(size) {
987
+ if (size !== '') { createColumn(parseInt(size, 10)).appendTo(row); }
988
+ });
989
+
990
+ return row;
991
+ }
992
+
993
+ function onScroll(e) {
994
+ var $window = $(window);
995
+
996
+ if (
997
+ $window.scrollTop() > mainControls.offset().top &&
998
+ $window.scrollTop() < canvas.offset().top + canvas.height()
999
+ ) {
1000
+ if (wrapper.hasClass('ge-top')) {
1001
+ wrapper
1002
+ .css({
1003
+ left: wrapper.offset().left,
1004
+ width: wrapper.outerWidth(),
1005
+ })
1006
+ .removeClass('ge-top')
1007
+ .addClass('ge-fixed')
1008
+ ;
1009
+ }
1010
+ } else {
1011
+ if (wrapper.hasClass('ge-fixed')) {
1012
+ wrapper
1013
+ .css({ left: '', width: '' })
1014
+ .removeClass('ge-fixed')
1015
+ .addClass('ge-top')
1016
+ ;
1017
+ }
1018
+ }
1019
+ }
1020
+
1021
+ function initRTE(e) {
1022
+ if ($(this).hasClass('ge-rte-active')) { return; }
1023
+
1024
+ // A content area nobody can see - a tab that is not the open one,
1025
+ // a closed accordion item - has no geometry for an editor to lay
1026
+ // its toolbar out against, and nothing anyone can type into
1027
+ if (!$(this).is(':visible')) { return; }
1028
+
1029
+ var rte = getRTE($(this).data('ge-content-type'));
1030
+ if (rte) {
1031
+ $(this).addClass('ge-rte-active', true);
1032
+ rte.init(settings, $(this));
1033
+ }
1034
+ }
1035
+
1036
+ function reset() {
1037
+ deinit();
1038
+ init();
1039
+ }
1040
+
1041
+ function init() {
1042
+ runFilter(true);
1043
+ canvas.addClass('ge-editing');
1044
+ canvas.toggleClass('ge-drag-drawer', settings.drag_handle === 'drawer');
1045
+ addAllColClasses();
1046
+ wrapContent();
1047
+ createRowControls();
1048
+ createColControls();
1049
+ markContainers();
1050
+ plugins('onInit');
1051
+ makeSortable();
1052
+ makeResizable();
1053
+ switchLayout(curView);
1054
+ }
1055
+
1056
+ function deinit() {
1057
+ canvas.removeClass('ge-editing ge-drag-drawer ge-dropping');
1058
+ var contents = canvas.find('.ge-content').each(function() {
1059
+ var content = $(this);
1060
+ var rte = getRTE(content.data('ge-content-type'));
1061
+ if (rte) {
1062
+ rte.deinit(settings, content);
1063
+ }
1064
+ // Cleared after rte.deinit, not before: an editor can restore the
1065
+ // class attribute it snapshotted when it was created, which would
1066
+ // leave ge-rte-active in place and make initRTE ignore every later
1067
+ // click on this content area.
1068
+ content.removeClass('ge-rte-active');
1069
+ });
1070
+ closeSizePicker();
1071
+ hideDropMarker();
1072
+ canvas.find('.ge-tools-drawer').remove();
1073
+ plugins('onDeinit');
1074
+ unmarkContainers();
1075
+ removeSortable();
1076
+ removeResizable();
1077
+ runFilter(false);
1078
+ }
1079
+
1080
+ /**
1081
+ * The markup as a host would save it: no drawers, no editor, no
1082
+ * sortables. The canvas goes back to editing afterwards.
1083
+ */
1084
+ function getHtml() {
1085
+ deinit();
1086
+ stripPixelWidths(canvas);
1087
+ var html = canvas.html();
1088
+ init();
1089
+ return html;
1090
+ }
1091
+
1092
+ function destroy() {
1093
+ deinit();
1094
+ removeConfirmModal();
1095
+ mainControls.remove();
1096
+ htmlTextArea.remove();
1097
+ $(window).off('scroll', onScroll);
1098
+ canvas.off('click', '.ge-content', initRTE);
1099
+ canvas.off('ge-rte-ready', '.ge-content');
1100
+ canvas.removeData('grideditor');
1101
+ }
1102
+
1103
+ function deprecatedRemove() {
1104
+ warnOnceHere('remove', 'remove() is deprecated and will be removed in a later ' +
1105
+ 'release. Use destroy(), which does the same thing.');
1106
+ destroy();
1107
+ }
1108
+
1109
+
1110
+ /** The elements of one content area: its marked children, or all of them. */
1111
+
1112
+ /**
1113
+ * Give every element its class, its drawer and, while editing, the
1114
+ * contenteditable="false" that makes a rich text editor treat it as
1115
+ * one atomic thing rather than as text it may rewrite.
1116
+ *
1117
+ * The class is re-applied on every init rather than trusted to
1118
+ * survive: an editor that snapshots and restores the markup inside a
1119
+ * content area can drop it, and the marking that identifies an
1120
+ * element lives in a data attribute for exactly that reason.
1121
+ */
1122
+
1123
+
1124
+
1125
+ /**
1126
+ * What the info tool calls this element: its label, its type, or both.
1127
+ * An element found by elements.auto has neither, so it is named after
1128
+ * its tag, which is the only thing it has said about itself.
1129
+ */
1130
+
1131
+ /**
1132
+ * The container plugins this editor is using: the ones registered by
1133
+ * the files the page loaded, narrowed by the plugins setting.
1134
+ *
1135
+ * Each is a factory, called once here with the handle it works
1136
+ * through. Everything a plugin needs from the editor goes through
1137
+ * that handle, because the closure it runs outside of is not
1138
+ * something it can see.
1139
+ */
1140
+ function loadPlugins() {
1141
+ var api = pluginApi();
1142
+ var wanted = function(name) {
1143
+ return !settings.plugins || settings.plugins.indexOf(name) !== -1;
1144
+ };
1145
+
1146
+ $.each($.fn.gridEditor.containers, function(type, factory) {
1147
+ if (wanted(type)) { CONTAINERS[type] = factory(api); }
1148
+ });
1149
+
1150
+ $.each($.fn.gridEditor.features, function(name, factory) {
1151
+ if (wanted(name)) { FEATURES[name] = factory(api); }
1152
+ });
1153
+
1154
+ $.each(FEATURES, function(name, feature) {
1155
+ $.each(feature.methods || {}, function(method, implementation) {
1156
+ featureMethods[method] = implementation;
1157
+ });
1158
+ });
1159
+
1160
+ (settings.plugins || []).forEach(function(name) {
1161
+ if (CONTAINERS[name] || FEATURES[name]) { return; }
1162
+
1163
+ warnOnceHere('plugin:' + name, 'the "' + name + '" plugin is not loaded: ' +
1164
+ 'include dist/plugins/grideditor.' + name + '.js after the editor');
1165
+ });
1166
+ }
1167
+
1168
+ /**
1169
+ * A hook every loaded plugin may have. Containers first, since a
1170
+ * feature that looks at the canvas - elements, say - wants the
1171
+ * containers already marked.
1172
+ */
1173
+ function plugins(hook, argument) {
1174
+ $.each(CONTAINERS, function(type, definition) {
1175
+ if (definition[hook]) { definition[hook](argument); }
1176
+ });
1177
+ $.each(FEATURES, function(name, feature) {
1178
+ if (feature[hook]) { feature[hook](argument); }
1179
+ });
1180
+ }
1181
+
1182
+ /** What a plugin is handed. See docs/plugins.md. */
1183
+ function pluginApi() {
1184
+ return {
1185
+ canvas: canvas,
1186
+ settings: settings,
1187
+ t: t,
1188
+ warn: warn,
1189
+ containerId: containerId,
1190
+ defaultRegion: defaultRegion,
1191
+ createTool: createTool,
1192
+ createMoveTool: createMoveTool,
1193
+ addSettingsTool: addSettingsTool,
1194
+ deleteNode: deleteNode,
1195
+ place: place,
1196
+ createPaneControls: createPaneControls,
1197
+ makeLabelEditable: makeLabelEditable,
1198
+ labelIn: labelIn,
1199
+ unwrapLabels: unwrapLabels,
1200
+ suspendToggles: suspendToggles,
1201
+ resumeToggles: resumeToggles,
1202
+ emit: emit,
1203
+ payloadFor: payloadFor,
1204
+ operate: operate,
1205
+ };
1206
+ }
1207
+
1208
+ /* --------------------------------------------------------------
1209
+ * Containers: tabs, accordions and popups.
1210
+ *
1211
+ * A container holds panes, and a pane is an ordinary canvas region -
1212
+ * rows, columns, content areas and elements nest inside one exactly
1213
+ * as they do at the top level, because init() walks the whole canvas
1214
+ * and does not care how deep it is.
1215
+ *
1216
+ * What a container is, is said by data-ge-container. The ge-* classes
1217
+ * are editing furniture and come off with everything else, so a host
1218
+ * restyling the markup cannot break detection and getHtml stays clean.
1219
+ * -------------------------------------------------------------- */
1220
+
1221
+ var CONTAINERS = {}; // The container plugins in use, by the type each builds
1222
+ var FEATURES = {}; // The feature plugins in use, by name
1223
+ var featureMethods = {}; // The methods those features contribute
1224
+ var containerCounter = 0;
1225
+
1226
+ /**
1227
+ * Ids go into the markup rather than into jQuery data: Bootstrap's
1228
+ * toggles are written in terms of them, and the markup has to survive
1229
+ * getHtml with those toggles still pointing at the right panes.
1230
+ */
1231
+ function containerId(type) {
1232
+ containerCounter++;
1233
+
1234
+ return 'ge-' + type + '-' + containerCounter + '-' +
1235
+ Math.random().toString(36).slice(2, 6);
1236
+ }
1237
+
1238
+ /** A pane's starting content: one full width column, ready to edit. */
1239
+ function defaultRegion() {
1240
+ var row = createRow();
1241
+ createColumn(MAX_COL_SIZE).appendTo(row);
1242
+ return row;
1243
+ }
1244
+
1245
+ function containerTypeOf(container) {
1246
+ return container.attr('data-ge-container');
1247
+ }
1248
+
1249
+ function markContainers() {
1250
+ canvas.find('[data-ge-container]').each(function() {
1251
+ var container = $(this);
1252
+ var type = containerTypeOf(container);
1253
+ var definition = CONTAINERS[type];
1254
+
1255
+ if (!definition) {
1256
+ warnOnceHere('container:' + type, 'unknown container type "' + type + '"');
1257
+ return;
1258
+ }
1259
+
1260
+ container.addClass('ge-container ge-container-' + type);
1261
+ definition.mark(container);
1262
+
1263
+ if (!container.find('> .ge-tools-drawer').length) {
1264
+ createContainerControls(container, type, definition);
1265
+ }
1266
+ });
1267
+ }
1268
+
1269
+ function unmarkContainers() {
1270
+ canvas.find('[data-ge-container]').each(function() {
1271
+ var container = $(this);
1272
+ var definition = CONTAINERS[containerTypeOf(container)];
1273
+
1274
+ if (definition) { definition.unmark(container); }
1275
+
1276
+ container.removeClass('ge-container ge-container-' + containerTypeOf(container));
1277
+ if (!container.attr('class')) { container.removeAttr('class'); }
1278
+ });
1279
+ }
1280
+
1281
+ function createContainerControls(container, type, definition) {
1282
+ var drawer = $('<div class="ge-tools-drawer ge-container-drawer" />').prependTo(container);
1283
+
1284
+ createMoveTool(drawer);
1285
+ addSettingsTool(drawer, container, settings.container_classes);
1286
+ if (definition.addPane) {
1287
+ createTool(drawer, t(definition.addPaneKey), 'ge-add-pane', 'bi bi-plus-circle', function() {
1288
+ var pane = definition.addPane(container, {});
1289
+
1290
+ addNode(definition.paneKind, pane, function() {}, {
1291
+ parent: container,
1292
+ source: 'tool',
1293
+ container: container,
1294
+ });
1295
+ });
1296
+ }
1297
+
1298
+ settings.container_tools.forEach(function(hostTool) {
1299
+ createTool(drawer, hostTool.title || '', hostTool.className || '',
1300
+ hostTool.iconClass || 'bi bi-wrench', hostTool.on);
1301
+ });
1302
+
1303
+ if (definition.tools) { definition.tools(drawer, container); }
1304
+
1305
+ createTool(drawer, t('tool.delete_container'), 'ge-delete-container', 'bi bi-trash', function() {
1306
+ deleteNode(type, container, t('confirm.delete_container'), function(removed) {
1307
+ container.slideUp(removed);
1308
+ });
1309
+ });
1310
+ }
1311
+
1312
+ /**
1313
+ * A pane drawer: small, inline, and made the same way for every
1314
+ * container type so a tab and an accordion item behave alike.
1315
+ */
1316
+ function createPaneControls(pane, kind, hostTools, confirmText, remove) {
1317
+ var drawer = $('<div class="ge-tools-drawer ge-pane-drawer" />').prependTo(pane);
1318
+
1319
+ createMoveTool(drawer);
1320
+ addSettingsTool(drawer, pane, settings.pane_classes);
1321
+
1322
+ hostTools.forEach(function(hostTool) {
1323
+ createTool(drawer, hostTool.title || '', hostTool.className || '',
1324
+ hostTool.iconClass || 'bi bi-wrench', hostTool.on);
1325
+ });
1326
+
1327
+ createTool(drawer, t('tool.delete_pane'), 'ge-delete-pane', 'bi bi-trash', function() {
1328
+ deleteNode(kind, pane, confirmText, function(removed) {
1329
+ remove(removed);
1330
+ });
1331
+ });
1332
+
1333
+ return drawer;
1334
+ }
1335
+
1336
+ /**
1337
+ * Take a Bootstrap toggle away from a node, and give it back later.
1338
+ *
1339
+ * Bootstrap binds its data-api handlers on the document in the
1340
+ * capture phase, so a listener on the node cannot stop one: the
1341
+ * document sees the click first. What does work is leaving nothing
1342
+ * for its selector to match, so the attribute is moved aside while
1343
+ * the editor needs the control not to react, and moved back on the
1344
+ * way out.
1345
+ */
1346
+ function suspendToggles(scope) {
1347
+ scope.find('[data-bs-toggle], [data-bs-dismiss]').addBack('[data-bs-toggle], [data-bs-dismiss]')
1348
+ .each(function() {
1349
+ var node = $(this);
1350
+
1351
+ ['toggle', 'dismiss'].forEach(function(name) {
1352
+ var value = node.attr('data-bs-' + name);
1353
+ if (value === undefined) { return; }
1354
+
1355
+ node.attr('data-ge-bs-' + name, value).removeAttr('data-bs-' + name);
1356
+ });
1357
+ });
1358
+ }
1359
+
1360
+ function resumeToggles(scope) {
1361
+ scope.find('[data-ge-bs-toggle], [data-ge-bs-dismiss]').addBack('[data-ge-bs-toggle], [data-ge-bs-dismiss]')
1362
+ .each(function() {
1363
+ var node = $(this);
1364
+
1365
+ ['toggle', 'dismiss'].forEach(function(name) {
1366
+ var value = node.attr('data-ge-bs-' + name);
1367
+ if (value === undefined) { return; }
1368
+
1369
+ node.attr('data-bs-' + name, value).removeAttr('data-ge-bs-' + name);
1370
+ });
1371
+ });
1372
+ }
1373
+
1374
+ /**
1375
+ * Rename a label in place. The label sits inside the button Bootstrap
1376
+ * toggles from, so the toggle is suspended while the label is being
1377
+ * edited: typing in a tab's name must not switch tabs.
1378
+ */
1379
+ function makeLabelEditable(label) {
1380
+ if (label.data('ge-editable')) { return; }
1381
+
1382
+ var toggle = label.closest('[data-bs-toggle], [data-ge-bs-toggle]');
1383
+
1384
+ label.data('ge-editable', true)
1385
+ .attr('title', t('tool.rename'))
1386
+ .on('dblclick', function(e) {
1387
+ e.preventDefault();
1388
+ e.stopPropagation();
1389
+
1390
+ suspendToggles(toggle);
1391
+ label.attr('contenteditable', 'true').trigger('focus');
1392
+ window.getSelection().selectAllChildren(label[0]);
1393
+ })
1394
+ .on('keydown', function(e) {
1395
+ if (label.attr('contenteditable') !== 'true') { return; }
1396
+
1397
+ if (e.key === 'Enter') {
1398
+ e.preventDefault();
1399
+ label.trigger('blur');
1400
+ }
1401
+ })
1402
+ .on('blur', function() {
1403
+ label.removeAttr('contenteditable');
1404
+ resumeToggles(toggle);
1405
+ })
1406
+ ;
1407
+ }
1408
+
1409
+
1410
+ /** Exactly one tab is the active one, in the strip and in the content. */
1411
+
1412
+ /**
1413
+ * An accordion whose items carry no data-bs-parent is one Bootstrap
1414
+ * lets you open several items in at once. The markup is the state:
1415
+ * there is nothing else to remember it in.
1416
+ */
1417
+
1418
+
1419
+ /**
1420
+ * An item dropped into another accordion collapses against the one it
1421
+ * landed in, and takes on that accordion's idea of whether several
1422
+ * items may be open at once.
1423
+ */
1424
+ /**
1425
+ * Open or close an item while editing.
1426
+ *
1427
+ * The editor does this itself rather than letting Bootstrap's collapse
1428
+ * run over the canvas, and it writes what it does to data-ge-open, so
1429
+ * what is left open here is what the authored page opens with. An
1430
+ * accordion that closes its siblings - one without stay_open - closes
1431
+ * them here too, because the canvas is meant to look like the page.
1432
+ */
1433
+
1434
+ /** One item's state, in the attribute and in Bootstrap's own classes. */
1435
+
1436
+
1437
+
1438
+
1439
+ /**
1440
+ * A trigger is any node the host marked with data-ge-popup-target,
1441
+ * plus the button the container makes for itself. Ids go stale - a
1442
+ * popup deleted, a trigger pasted from another page - so what can be
1443
+ * repaired is repaired, and the rest is reported rather than removed.
1444
+ * Grid-editor never deletes a node the host wrote.
1445
+ */
1446
+
1447
+ /**
1448
+ * On the way out, every trigger gets the attributes that make
1449
+ * Bootstrap open the modal in the authored page. An orphan gets
1450
+ * nothing, because there is nothing to point it at.
1451
+ */
1452
+
1453
+
1454
+ /**
1455
+ * What each container type is made of, and what has to happen to one
1456
+ * while it is being edited. Everything type specific lives here; the
1457
+ * core above treats them all the same.
1458
+ */
1459
+ /** The label inside a pane's button, wrapped so it can be edited alone. */
1460
+ function labelIn(button) {
1461
+ var label = button.find('> .ge-pane-label');
1462
+
1463
+ if (!label.length) {
1464
+ label = $('<span class="ge-pane-label" />').text(button.text().trim());
1465
+ button.empty().append(label);
1466
+ }
1467
+
1468
+ return label;
1469
+ }
1470
+
1471
+ function unwrapLabels(scope) {
1472
+ scope.find('.ge-pane-label').each(function() {
1473
+ $(this).removeData('ge-editable').contents().unwrap();
1474
+ });
1475
+ }
1476
+
1477
+ /**
1478
+ * Add a column of `size` to a row, through the add events.
1479
+ */
1480
+ function addColumnTo(row, size) {
1481
+ var column = createColumn(size);
1482
+
1483
+ return addNode('column', column, function() {
1484
+ row.append(column);
1485
+ }, { parent: row, source: 'tool' });
1486
+ }
1487
+
1488
+ /**
1489
+ * Holding the add column tool offers the sizes instead of taking the
1490
+ * default one.
1491
+ *
1492
+ * Held rather than hovered alone, because a hover is a gesture a touch
1493
+ * screen does not have, and the tooltip says so: a gesture nobody can
1494
+ * see is a gesture nobody finds.
1495
+ */
1496
+ function attachSizePicker(tool, row) {
1497
+ if (!settings.add_column.picker) { return; }
1498
+
1499
+ var timer = null;
1500
+
1501
+ var cancel = function() {
1502
+ window.clearTimeout(timer);
1503
+ timer = null;
1504
+ };
1505
+
1506
+ tool.on('mouseenter mousedown', function() {
1507
+ if (timer || sizePicker) { return; }
1508
+
1509
+ timer = window.setTimeout(function() {
1510
+ timer = null;
1511
+ openSizePicker(tool, row);
1512
+ }, settings.add_column.delay);
1513
+ });
1514
+
1515
+ tool.on('mouseleave', cancel);
1516
+ tool.on('mouseup', cancel);
1517
+ }
1518
+
1519
+ /**
1520
+ * The sizes a column may be given, as a strip under the tool. Sizes
1521
+ * that do not fit what is left of the row are marked, not withheld:
1522
+ * a row is allowed to wrap, and that is the host's page to lay out.
1523
+ */
1524
+ function openSizePicker(tool, row) {
1525
+ closeSizePicker();
1526
+
1527
+ var room = spare(row, leadingTier());
1528
+
1529
+ // The drawer it hangs off is raised while it is open: every
1530
+ // drawer sits above jQuery UI's handles, so without this the
1531
+ // drawer of the column below takes the clicks meant for the picker
1532
+ tool.closest('.ge-tools-drawer').addClass('ge-picker-open');
1533
+
1534
+ sizePicker = $('<div class="ge-size-picker" />').appendTo(tool);
1535
+
1536
+ settings.valid_col_sizes.forEach(function(size) {
1537
+ $('<a class="ge-size" />')
1538
+ .attr('data-ge-size', size)
1539
+ .attr('title', t('tool.column_size', { size: size }))
1540
+ .toggleClass('ge-size-tight', size > room)
1541
+ .text(size)
1542
+ .on('click', function(e) {
1543
+ e.preventDefault();
1544
+ e.stopPropagation();
1545
+
1546
+ closeSizePicker();
1547
+ addColumnTo(row, size);
1548
+ })
1549
+ .appendTo(sizePicker)
1550
+ ;
1551
+ });
1552
+
1553
+ // Anywhere else, and the question is withdrawn
1554
+ tool.one('mouseleave', function() {
1555
+ window.setTimeout(function() {
1556
+ if (sizePicker && !sizePicker.is(':hover')) { closeSizePicker(); }
1557
+ }, 400);
1558
+ });
1559
+ }
1560
+
1561
+ /** True when there was one to close, which is also a click's answer. */
1562
+ function closeSizePicker() {
1563
+ if (!sizePicker) { return false; }
1564
+
1565
+ sizePicker.closest('.ge-tools-drawer').removeClass('ge-picker-open');
1566
+ sizePicker.remove();
1567
+ sizePicker = null;
1568
+
1569
+ return true;
1570
+ }
1571
+
1572
+ function createRowControls() {
1573
+ canvas.find('.row').each(function() {
1574
+ var row = $(this);
1575
+ if (row.find('> .ge-tools-drawer').length) { return; }
1576
+
1577
+ var drawer = $('<div class="ge-tools-drawer" />').prependTo(row);
1578
+ createMoveTool(drawer);
1579
+ addSettingsTool(drawer, row, settings.row_classes);
1580
+
1581
+ settings.row_tools.forEach(function(hostTool) {
1582
+ createTool(drawer, hostTool.title || '', hostTool.className || '',
1583
+ hostTool.iconClass || 'bi bi-wrench', hostTool.on);
1584
+ });
1585
+ createTool(drawer, t('tool.delete_row'), 'ge-delete-row', 'bi bi-trash', function() {
1586
+ deleteNode('row', row, t('confirm.delete_row'), function(removed) {
1587
+ row.slideUp(removed);
1588
+ });
1589
+ });
1590
+ createTool(drawer, t('tool.add_column'), 'ge-add-column', 'bi bi-plus-circle', function() {
1591
+ if (closeSizePicker()) { return; } // The picker was open: that was the answer
1592
+
1593
+ addColumnTo(row, settings.add_column.size);
1594
+ });
1595
+
1596
+ attachSizePicker(drawer.find('> .ge-add-column'), row);
1597
+
1598
+ });
1599
+ }
1600
+
1601
+ function createColControls() {
1602
+ canvas.find('.column').each(function() {
1603
+ var col = $(this);
1604
+ if (col.find('> .ge-tools-drawer').length) { return; }
1605
+
1606
+ var drawer = $('<div class="ge-tools-drawer" />').prependTo(col);
1607
+
1608
+ createMoveTool(drawer);
1609
+
1610
+ createTool(drawer, t('tool.column_narrower'), 'ge-decrease-col-width', 'bi bi-dash-lg', function(e) {
1611
+ resizeColumn(col, e.shiftKey
1612
+ ? smallest(settings.valid_col_sizes)
1613
+ : stepThrough(settings.valid_col_sizes, currentSize(col), -1),
1614
+ 'tool');
1615
+ });
1616
+
1617
+ createTool(drawer, t('tool.column_wider'), 'ge-increase-col-width', 'bi bi-plus-lg', function(e) {
1618
+ resizeColumn(col, e.shiftKey ? widestFor(col) : stepThrough(settings.valid_col_sizes, currentSize(col), 1), 'tool');
1619
+ });
1620
+
1621
+ createTool(drawer, t('tool.indent_decrease'), 'ge-decrease-col-offset', 'bi bi-text-indent-right', function(e) {
1622
+ indentColumn(col, e.shiftKey
1623
+ ? smallest(settings.valid_col_offsets)
1624
+ : stepThrough(settings.valid_col_offsets, currentOffset(col), -1),
1625
+ 'tool');
1626
+ });
1627
+
1628
+ createTool(drawer, t('tool.indent_increase'), 'ge-increase-col-offset', 'bi bi-text-indent-left', function(e) {
1629
+ indentColumn(col, e.shiftKey ? deepestFor(col) : stepThrough(settings.valid_col_offsets, currentOffset(col), 1), 'tool');
1630
+ });
1631
+
1632
+ addSettingsTool(drawer, col, settings.col_classes);
1633
+
1634
+ settings.col_tools.forEach(function(hostTool) {
1635
+ createTool(drawer, hostTool.title || '', hostTool.className || '',
1636
+ hostTool.iconClass || 'bi bi-wrench', hostTool.on);
1637
+ });
1638
+
1639
+ createTool(drawer, t('tool.delete_column'), 'ge-delete-column', 'bi bi-trash', function() {
1640
+ deleteNode('column', col, t('confirm.delete_column'), function(removed) {
1641
+ col.animate({
1642
+ opacity: 'hide',
1643
+ width: 'hide',
1644
+ height: 'hide'
1645
+ }, 400, removed);
1646
+ });
1647
+ });
1648
+
1649
+ createTool(drawer, t('tool.add_row'), 'ge-add-row', 'bi bi-plus-circle', function() {
1650
+ // An empty row: the columns in it are the next decision,
1651
+ // and its drawer's add column tool is where that is made
1652
+ var row = createRow();
1653
+
1654
+ addNode('row', row, function() {
1655
+ col.append(row);
1656
+ }, { parent: col, source: 'tool' });
1657
+ });
1658
+
1659
+ });
1660
+ }
1661
+
1662
+ /**
1663
+ * The tier the tools read when they need one number.
1664
+ *
1665
+ * In a per-breakpoint view that is the tier being edited. In the all
1666
+ * view it is the widest tier, because the canvas is not constrained
1667
+ * there and the widest tier is what the user is looking at: clicking
1668
+ * "narrower" on a column authored as col-lg-6 should take it to 5,
1669
+ * not to 11 because no xs class was ever written.
1670
+ */
1671
+ function leadingTier() {
1672
+ return curView === ALL_VIEW ? BREAKPOINTS[BREAKPOINTS.length - 1] : breakpoint(curView);
1673
+ }
1674
+
1675
+ function currentSize(col) {
1676
+ var size = getEffectiveSize(col, leadingTier());
1677
+ return size === null ? MAX_COL_SIZE : size;
1678
+ }
1679
+
1680
+ function currentOffset(col) {
1681
+ return getEffectiveOffset(col, leadingTier()) || 0;
1682
+ }
1683
+
1684
+ /** The next value a tool moves to, one step along the allowed list. */
1685
+ function stepThrough(values, from, direction) {
1686
+ var index = values.indexOf(from);
1687
+
1688
+ if (index === -1) {
1689
+ // A value the host did not allow: step to the nearest one it did
1690
+ return values.reduce(function(best, value) {
1691
+ return Math.abs(value - from) < Math.abs(best - from) ? value : best;
1692
+ }, values[0]);
1693
+ }
1694
+
1695
+ return values[Math.min(Math.max(index + direction, 0), values.length - 1)];
1696
+ }
1697
+
1698
+ function smallest(values) {
1699
+ return values.reduce(function(a, b) { return Math.min(a, b); }, MAX_COL_SIZE);
1700
+ }
1701
+
1702
+ function largest(values) {
1703
+ return values.reduce(function(a, b) { return Math.max(a, b); }, 0);
1704
+ }
1705
+
1706
+ /**
1707
+ * The widest this column can be: everything the row has left, minus
1708
+ * its own indent. What "hold shift for max" means.
1709
+ */
1710
+ function widestFor(col) {
1711
+ var room = spare(col.parent(), leadingTier(), col) - currentOffset(col);
1712
+
1713
+ return Math.min(largest(settings.valid_col_sizes), Math.max(room, 1));
1714
+ }
1715
+
1716
+ /**
1717
+ * The deepest this column can be indented and still have a unit of
1718
+ * itself left inside the row.
1719
+ */
1720
+ function deepestFor(col) {
1721
+ var room = spare(col.parent(), leadingTier(), col) - currentSize(col);
1722
+
1723
+ return Math.min(largest(settings.valid_col_offsets), Math.max(room, 0));
1724
+ }
1725
+
1726
+ /**
1727
+ * The move tool, unless the whole drawer is the handle - in which case
1728
+ * a tool that only says "drag from here" is one tool too many.
1729
+ */
1730
+ function createMoveTool(drawer) {
1731
+ if (settings.drag_handle === 'drawer') { return; }
1732
+
1733
+ createTool(drawer, t('tool.move'), 'ge-move', 'bi bi-arrows-move');
1734
+ }
1735
+
1736
+ function createTool(drawer, title, className, iconClass, eventHandlers) {
1737
+ var tool = $('<a title="' + title + '" class="' + className + '"><i class="' + iconClass + '"></i></a>')
1738
+ .appendTo(drawer)
1739
+ ;
1740
+ if (typeof eventHandlers == 'function') {
1741
+ tool.on('click', eventHandlers);
1742
+ }
1743
+ if (typeof eventHandlers == 'object') {
1744
+ $.each(eventHandlers, function(name, func) {
1745
+ tool.on(name, func);
1746
+ });
1747
+ }
1748
+ }
1749
+
1750
+ /**
1751
+ * The classes on a node that are the host's own, rather than the ones
1752
+ * the grid and the editor put there. What the settings panel shows,
1753
+ * and the only ones it is allowed to take away.
1754
+ */
1755
+ function hostClasses(node) {
1756
+ return (node.attr('class') || '').split(/\s+/).filter(function(name) {
1757
+ return name !== '' && !isEditorClass(name);
1758
+ });
1759
+ }
1760
+
1761
+ function isEditorClass(name) {
1762
+ if (name === 'row' || name === 'column') { return true; }
1763
+ if (/^(ge-|ui-)/.test(name)) { return true; }
1764
+
1765
+ return BREAKPOINTS.some(function(tier) {
1766
+ return new RegExp('^(' + tier.colPrefix + '|' + tier.offsetPrefix + ')\\d+$').test(name);
1767
+ });
1768
+ }
1769
+
1770
+ function setHostClasses(node, value) {
1771
+ hostClasses(node).forEach(function(name) { node.removeClass(name); });
1772
+
1773
+ value.split(/\s+/).forEach(function(name) {
1774
+ if (name !== '') { node.addClass(name); }
1775
+ });
1776
+
1777
+ if (!node.attr('class')) { node.removeAttr('class'); }
1778
+ }
1779
+
1780
+ /**
1781
+ * The gear and the panel it opens: the node's id, its css classes, and
1782
+ * whatever preset toggles the host configured for that kind of node.
1783
+ */
1784
+ function addSettingsTool(drawer, node, presets) {
1785
+ var details = createDetails(node, presets || []);
1786
+
1787
+ createTool(drawer, t('tool.settings'), 'ge-settings', 'bi bi-gear-fill', function() {
1788
+ details.toggle();
1789
+ });
1790
+
1791
+ return details.appendTo(drawer);
1792
+ }
1793
+
1794
+ function createDetails(container, cssClasses) {
1795
+ var detailsDiv = $('<div class="ge-details" />');
1796
+
1797
+ $('<input class="ge-id" />')
1798
+ .attr('placeholder', t('tool.id_placeholder'))
1799
+ .val(container.attr('id'))
1800
+ .attr('title', t('tool.id_title'))
1801
+ .appendTo(detailsDiv)
1802
+ .on('change', function() {
1803
+ // An empty field means no id, not an empty one
1804
+ if (this.value === '') {
1805
+ container.removeAttr('id');
1806
+ } else {
1807
+ container.attr('id', this.value);
1808
+ }
1809
+ })
1810
+ ;
1811
+
1812
+ $('<input class="ge-classes" />')
1813
+ .attr('placeholder', t('tool.classes_placeholder'))
1814
+ .attr('title', t('tool.classes_title'))
1815
+ .val(hostClasses(container).join(' '))
1816
+ .appendTo(detailsDiv)
1817
+ .on('change', function() {
1818
+ setHostClasses(container, this.value);
1819
+ })
1820
+ ;
1821
+
1822
+ var classGroup = $('<div class="btn-group" />').appendTo(detailsDiv);
1823
+ cssClasses.forEach(function(rowClass) {
1824
+ var btn = $('<a class="btn btn-sm btn-default" />')
1825
+ .html(rowClass.label)
1826
+ .attr('title', rowClass.title ? rowClass.title : t('tool.toggle_class', { label: rowClass.label }))
1827
+ .toggleClass('active btn-primary', container.hasClass(rowClass.cssClass))
1828
+ .on('click', function() {
1829
+ btn.toggleClass('active btn-primary');
1830
+ container.toggleClass(rowClass.cssClass, btn.hasClass('active'));
1831
+ })
1832
+ .appendTo(classGroup)
1833
+ ;
1834
+ });
1835
+
1836
+ return detailsDiv;
1837
+ }
1838
+
1839
+ /**
1840
+ * Make sure every column is marked as one, and that a column with no
1841
+ * sizing at all gets some.
1842
+ *
1843
+ * Deliberately conservative: a column that carries any size class is
1844
+ * left exactly as authored. Seeding every tier would put six classes
1845
+ * on every column now that there are six tiers, and the smallest tier
1846
+ * already applies to the wider ones, so one class is enough for a
1847
+ * column that had none.
1848
+ */
1849
+ function addAllColClasses() {
1850
+ canvas.find('.column, div[class*="col-"]').each(function() {
1851
+ var col = $(this).addClass('column');
1852
+
1853
+ if (sizedTiers(col).length) { return; }
1854
+
1855
+ setSize(col, BREAKPOINTS[0], MAX_COL_SIZE);
1856
+ });
1857
+ }
1858
+
1859
+ /* --------------------------------------------------------------
1860
+ * The sizing core.
1861
+ *
1862
+ * Everything that reads or writes a size or an offset class goes
1863
+ * through here: the width and indent tools, drag resize,
1864
+ * createColumn and the getHtml cleanup. One place owns the class
1865
+ * names, the 12 unit budget and what an absent class means.
1866
+ * -------------------------------------------------------------- */
1867
+
1868
+ /** The units a column is given at one tier, or null if that tier says nothing. */
1869
+ function getSize(col, tier) {
1870
+ return readUnits(col, tier.colPrefix);
1871
+ }
1872
+
1873
+ /** The units a column is indented by at one tier, or null. */
1874
+ function getOffset(col, tier) {
1875
+ return readUnits(col, tier.offsetPrefix);
1876
+ }
1877
+
1878
+ /**
1879
+ * What actually applies at a tier: its own class, or the nearest
1880
+ * smaller tier that has one, because that is how Bootstrap cascades.
1881
+ * Null when no tier below it says anything either.
1882
+ *
1883
+ * Not a "return the first thing I found" fallback: it walks the tiers
1884
+ * downward from the one asked about, so an offset lookup can never
1885
+ * answer with a size, and a lookup for one tier can never answer with
1886
+ * a wider tier's value.
1887
+ */
1888
+ function getEffectiveSize(col, tier) {
1889
+ return readEffective(col, tier, getSize);
1890
+ }
1891
+
1892
+ function getEffectiveOffset(col, tier) {
1893
+ return readEffective(col, tier, getOffset);
1894
+ }
1895
+
1896
+ function readEffective(col, tier, read) {
1897
+ for (var i = BREAKPOINTS.indexOf(tier); i >= 0; i--) {
1898
+ var units = read(col, BREAKPOINTS[i]);
1899
+ if (units !== null) { return units; }
1900
+ }
1901
+
1902
+ return null;
1903
+ }
1904
+
1905
+ function readUnits(col, prefix) {
1906
+ var match = new RegExp('(?:^|\\s)' + prefix + '(\\d+)(?:\\s|$)').exec(col.attr('class') || '');
1907
+ return match ? parseInt(match[1], 10) : null;
1908
+ }
1909
+
1910
+ function writeUnits(col, prefix, units) {
1911
+ var classes = (col.attr('class') || '').split(/\s+/).filter(function(name) {
1912
+ return name !== '' && !new RegExp('^' + prefix + '\\d+$').test(name);
1913
+ });
1914
+
1915
+ if (units !== null) { classes.push(prefix + units); }
1916
+
1917
+ col.attr('class', classes.join(' '));
1918
+ }
1919
+
1920
+ function setSize(col, tier, units) {
1921
+ writeUnits(col, tier.colPrefix, units);
1922
+ }
1923
+
1924
+ /** An offset of 0 is written as no class at all, which is what it means. */
1925
+ function setOffset(col, tier, units) {
1926
+ writeUnits(col, tier.offsetPrefix, units ? units : null);
1927
+ }
1928
+
1929
+ /** The tiers a column carries an explicit size for. */
1930
+ function sizedTiers(col) {
1931
+ return BREAKPOINTS.filter(function(tier) {
1932
+ return getSize(col, tier) !== null;
1933
+ });
1934
+ }
1935
+
1936
+ /**
1937
+ * The units left in a row at one tier, counting every sibling's size
1938
+ * and offset. What "hold shift for max" grows into.
1939
+ */
1940
+ function spare(row, tier, ignore) {
1941
+ var used = 0;
1942
+
1943
+ row.children('.column').each(function() {
1944
+ var sibling = $(this);
1945
+ if (ignore && sibling[0] === ignore[0]) { return; }
1946
+
1947
+ used += (getEffectiveSize(sibling, tier) || 0) + (getEffectiveOffset(sibling, tier) || 0);
1948
+ });
1949
+
1950
+ return MAX_COL_SIZE - used;
1951
+ }
1952
+
1953
+ /**
1954
+ * The 12 unit budget, in one place: a column's size plus its offset
1955
+ * never exceeds 12 at any tier.
1956
+ *
1957
+ * Which of the two gives way is the caller's decision, expressed by
1958
+ * which one it marks as leading. Growing an offset shrinks the column;
1959
+ * growing a column with no room left is refused, so the tool visibly
1960
+ * does nothing rather than quietly rewriting an offset the user set.
1961
+ */
1962
+ function clamp(request) {
1963
+ var size = request.size === null || request.size === undefined ? null : request.size;
1964
+ var offset = request.offset === null || request.offset === undefined ? 0 : request.offset;
1965
+
1966
+ offset = Math.min(Math.max(offset, 0), MAX_COL_OFFSET);
1967
+
1968
+ if (size === null) { return { size: null, offset: offset, refused: false }; }
1969
+
1970
+ size = Math.min(Math.max(size, 1), MAX_COL_SIZE);
1971
+
1972
+ if (size + offset <= MAX_COL_SIZE) {
1973
+ return { size: size, offset: offset, refused: false };
1974
+ }
1975
+
1976
+ if (request.leading === 'offset') {
1977
+ return { size: MAX_COL_SIZE - offset, offset: offset, refused: false };
1978
+ }
1979
+
1980
+ return { size: null, offset: offset, refused: true };
1981
+ }
1982
+
1983
+ /**
1984
+ * Drag resize leaves an inline pixel width behind, and jQuery UI adds
1985
+ * its own. Neither belongs in the markup a host saves, or in the
1986
+ * canvas once the size class has been written.
1987
+ */
1988
+ function stripPixelWidths(scope) {
1989
+ scope.find('.column').addBack('.column').each(function() {
1990
+ var col = $(this);
1991
+
1992
+ col.css({ width: '', height: '', left: '', top: '' });
1993
+
1994
+ // Clearing the last property leaves style="" behind, which is
1995
+ // an editor leftover like any other
1996
+ if (!col.attr('style')) { col.removeAttr('style'); }
1997
+ });
1998
+ }
1999
+
2000
+ function makeSortable() {
2001
+ var wholeDrawer = settings.drag_handle === 'drawer';
2002
+
2003
+ var shared = {
2004
+ handle: wholeDrawer ? '> .ge-tools-drawer' : '> .ge-tools-drawer .ge-move',
2005
+
2006
+ // With the whole drawer as the handle, the tools inside it are
2007
+ // still tools: a drag starting on one would swallow its click,
2008
+ // and the settings panel has fields to type in
2009
+ cancel: wholeDrawer
2010
+ ? '.ge-tools-drawer > a, .ge-details, input, textarea, button, select, option'
2011
+ : 'input, textarea, button, select, option',
2012
+
2013
+ start: sortStart,
2014
+ stop: sortStop,
2015
+ helper: 'clone',
2016
+ };
2017
+
2018
+ canvas.find('.row').sortable($.extend({
2019
+ items: '> .column',
2020
+ connectWith: '.ge-canvas .row',
2021
+ tolerance: 'pointer',
2022
+ }, shared, settings.sortable_options));
2023
+
2024
+ canvas.add(canvas.find('.column')).sortable($.extend({
2025
+ items: '> .row, > .ge-content',
2026
+ connectWith: '.ge-canvas, .ge-canvas .column',
2027
+ }, shared, settings.sortable_options));
2028
+
2029
+ // A tab strip sorts its own tabs, and the panes follow them
2030
+ canvas.find('.ge-container-tabs > .nav-tabs').sortable($.extend({
2031
+ items: '> .ge-tab',
2032
+ }, shared, settings.sortable_options));
2033
+
2034
+ // Accordion items sort within their accordion and into any other
2035
+ canvas.find('.ge-container-accordion > .accordion').sortable($.extend({
2036
+ items: '> .ge-accordion-item',
2037
+ connectWith: '.ge-canvas .ge-container-accordion > .accordion',
2038
+ }, shared, settings.sortable_options));
2039
+
2040
+ // A plugin makes its own: only it knows which of its parts move
2041
+ plugins('onSortable', shared);
2042
+
2043
+ /**
2044
+ * jQuery UI cannot refuse a drag once it has started, so a
2045
+ * canceled before-move is remembered here and undone on drop
2046
+ * (spec 2.4). The node carries the mark, because with connected
2047
+ * lists the drop is not always reported by the list that started
2048
+ * the drag.
2049
+ */
2050
+ function sortStart(e, ui) {
2051
+ ui.placeholder.css({ height: ui.item.outerHeight()});
2052
+
2053
+ var node = ui.item;
2054
+ var from = positionOf(node);
2055
+
2056
+ node.data('ge-move-from', from);
2057
+ node.removeData('ge-move-canceled');
2058
+
2059
+ operate(function() {
2060
+ var moving = emit('before-move', payloadFor(kindOf(node), node, {
2061
+ parent: from.parent,
2062
+ source: 'dragdrop',
2063
+ from: from,
2064
+ }));
2065
+
2066
+ if (!moving) { node.data('ge-move-canceled', true); }
2067
+ });
2068
+ }
2069
+
2070
+ function sortStop(e, ui) {
2071
+ var node = ui.item;
2072
+ var from = node.data('ge-move-from') || positionOf(node);
2073
+
2074
+ node.removeData('ge-move-from');
2075
+
2076
+ if (node.data('ge-move-canceled')) {
2077
+ node.removeData('ge-move-canceled');
2078
+ $(this).sortable('cancel');
2079
+ return;
2080
+ }
2081
+
2082
+ var to = positionOf(node);
2083
+ if (to.parent[0] === from.parent[0] && to.index === from.index) {
2084
+ return; // A drag that went nowhere is not a move
2085
+ }
2086
+
2087
+ var container = node.closest('[data-ge-container]');
2088
+ var definition = CONTAINERS[containerTypeOf(container)];
2089
+ if (definition && definition.afterPaneMove) {
2090
+ definition.afterPaneMove(container, node, from);
2091
+ }
2092
+
2093
+ // No init() here, unlike an add: the node brought its drawer
2094
+ // with it, and jQuery UI is still finishing the drag, so this
2095
+ // is the wrong moment to rebuild the widgets it is using.
2096
+ operate(function() {
2097
+ emit('after-move', payloadFor(kindOf(node), node, {
2098
+ parent: to.parent,
2099
+ source: 'dragdrop',
2100
+ from: from,
2101
+ to: to,
2102
+ container: container.length ? container : undefined,
2103
+ }));
2104
+ });
2105
+ }
2106
+ }
2107
+
2108
+ /**
2109
+ * Resizing a column by dragging its edge.
2110
+ *
2111
+ * The handle sits on the column's edge and the sort handle is the
2112
+ * drawer, so the two gestures never fight over the same pixels. The
2113
+ * column follows the pointer in pixels while dragging, the drawer says
2114
+ * which class it would land on, and the pixels are snapped to whole
2115
+ * units and thrown away on drop.
2116
+ */
2117
+ function makeResizable() {
2118
+ if (!settings.resize.enabled) { return; }
2119
+
2120
+ canvas.find('.column').each(function() {
2121
+ var col = $(this);
2122
+ if (col.data('ui-resizable')) { return; }
2123
+
2124
+ $('<span class="ge-resize-size" />').appendTo(col.find('> .ge-tools-drawer'));
2125
+
2126
+ col.resizable($.extend({
2127
+ handles: settings.resize.handles,
2128
+ start: resizeStart,
2129
+ resize: resizeMove,
2130
+ stop: resizeStop,
2131
+ }, settings.resizable_options));
2132
+ });
2133
+ }
2134
+
2135
+ function removeResizable() {
2136
+ canvas.find('.column').each(function() {
2137
+ var col = $(this);
2138
+ if (col.data('ui-resizable')) { col.resizable('destroy'); }
2139
+ });
2140
+
2141
+ canvas.find('.ge-resize-size').remove();
2142
+ stripPixelWidths(canvas);
2143
+ }
2144
+
2145
+ /**
2146
+ * The units a pixel width comes to, snapped to whole ones and held
2147
+ * inside the same budget the tools obey. With balance 'next' the
2148
+ * column may grow into its neighbour, which is what dragging the
2149
+ * divider between two columns looks like it should do.
2150
+ */
2151
+ function snapUnits(col, pixels) {
2152
+ var row = col.parent();
2153
+ var style = window.getComputedStyle(row[0]);
2154
+ var content = row[0].clientWidth -
2155
+ parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
2156
+
2157
+ var units = Math.round(pixels / content * MAX_COL_SIZE);
2158
+ var next = balanceSibling(col);
2159
+
2160
+ // With a sibling to balance against, the drag may take that
2161
+ // column's units but not its last one. Without one, the row is
2162
+ // allowed to wrap - that is what balance false means - so the only
2163
+ // limit is the column's own budget against its indent.
2164
+ var room = next
2165
+ ? currentSize(col) + currentSize(next) - smallest(settings.valid_col_sizes)
2166
+ : MAX_COL_SIZE - currentOffset(col);
2167
+
2168
+ return Math.min(
2169
+ Math.max(units, smallest(settings.valid_col_sizes)),
2170
+ Math.max(room, smallest(settings.valid_col_sizes)),
2171
+ largest(settings.valid_col_sizes)
2172
+ );
2173
+ }
2174
+
2175
+ /** The column that absorbs the delta, when the host asked for that. */
2176
+ function balanceSibling(col) {
2177
+ if (settings.resize.balance !== 'next') { return null; }
2178
+
2179
+ var next = col.nextAll('.column').first();
2180
+ return next.length ? next : null;
2181
+ }
2182
+
2183
+ function resizeReadout(col, text) {
2184
+ col.find('> .ge-tools-drawer > .ge-resize-size').text(text);
2185
+ }
2186
+
2187
+ function sizeLabel(units) {
2188
+ return (curView === ALL_VIEW ? BREAKPOINTS[0].colPrefix : leadingTier().colPrefix) + units;
2189
+ }
2190
+
2191
+ /**
2192
+ * A canceled before-resize refuses the drag.
2193
+ *
2194
+ * jQuery UI's resizable ignores false from its start handler - unlike
2195
+ * draggable, and unlike what the spec assumed - so the refusal is
2196
+ * carried on the column and every step of the drag returns false,
2197
+ * which the widget does honour. Nothing is written and the column ends
2198
+ * where it began.
2199
+ */
2200
+ function resizeStart(e, ui) {
2201
+ var col = $(this);
2202
+ var from = currentSize(col);
2203
+
2204
+ var allowed = operate(function() {
2205
+ return emit('before-resize', payloadFor('column', col, {
2206
+ source: 'dragdrop',
2207
+ from: from,
2208
+ to: null, // Not known until the pointer stops
2209
+ }));
2210
+ });
2211
+
2212
+ if (!allowed) {
2213
+ col.data('ge-resize-refused', true);
2214
+ return false;
2215
+ }
2216
+
2217
+ col.data('ge-resize-from', from);
2218
+ resizeReadout(col, sizeLabel(from));
2219
+
2220
+ return undefined;
2221
+ }
2222
+
2223
+ function resizeMove(e, ui) {
2224
+ var col = $(this);
2225
+
2226
+ if (col.data('ge-resize-refused')) { return false; }
2227
+
2228
+ resizeReadout(col, sizeLabel(snapUnits(col, ui.size.width)));
2229
+
2230
+ return undefined;
2231
+ }
2232
+
2233
+ function resizeStop(e, ui) {
2234
+ var col = $(this);
2235
+ var from = col.data('ge-resize-from');
2236
+ var units = snapUnits(col, ui.size.width);
2237
+
2238
+ col.removeData('ge-resize-from');
2239
+ resizeReadout(col, '');
2240
+ stripPixelWidths(col);
2241
+
2242
+ if (col.data('ge-resize-refused') || from === undefined) {
2243
+ col.removeData('ge-resize-refused');
2244
+ return;
2245
+ }
2246
+
2247
+ // A drag of a couple of pixels lands on the size it started from,
2248
+ // and is not a resize
2249
+ if (units === from) { return; }
2250
+
2251
+ var plan = planSize(col, units);
2252
+ if (!plan) { return; }
2253
+
2254
+ operate(function() {
2255
+ writeSize(col, plan);
2256
+ balanceAfterResize(col, plan.size - from);
2257
+
2258
+ emit('after-resize', payloadFor('column', col, {
2259
+ source: 'dragdrop',
2260
+ from: from,
2261
+ to: plan.size,
2262
+ }));
2263
+ });
2264
+ }
2265
+
2266
+ /**
2267
+ * Move the delta into the following column, so a full row stays full.
2268
+ * It is part of the same gesture, so it is not announced separately.
2269
+ */
2270
+ function balanceAfterResize(col, delta) {
2271
+ var next = balanceSibling(col);
2272
+ if (!next || !delta) { return; }
2273
+
2274
+ var plan = planSize(next, currentSize(next) - delta);
2275
+ if (plan) { writeSize(next, plan); }
2276
+ }
2277
+
2278
+ function removeSortable() {
2279
+ // Only where a sortable was actually made: deinit() is a public
2280
+ // method now, and jQuery UI throws when asked to destroy a widget
2281
+ // that is not there, so calling deinit() twice would fail.
2282
+ // jQuery UI marks what it made, so a plugin's sortables come
2283
+ // away with the editor's without the core knowing about them
2284
+ canvas.find('.ui-sortable').addBack('.ui-sortable').each(function() {
2285
+ var node = $(this);
2286
+ if (node.data('ui-sortable')) {
2287
+ node.sortable('destroy');
2288
+ }
2289
+ });
2290
+ }
2291
+
2292
+ function createRow() {
2293
+ return $('<div class="row" />');
2294
+ }
2295
+
2296
+ /**
2297
+ * Put a freshly created node where the caller asked for it, through
2298
+ * the add events, and bring the canvas up to date so the new markup
2299
+ * gets its controls. With no placement option the node stays
2300
+ * detached, and placing it and calling reset() is the host's job
2301
+ * (spec 1.2).
2302
+ *
2303
+ * Returns the node, or null when a handler canceled the add. A call
2304
+ * made from inside an event handler is queued, and then returns the
2305
+ * node without knowing yet whether the add will be canceled.
2306
+ */
2307
+ function place(node, kind, options) {
2308
+ var placement = null;
2309
+
2310
+ PLACEMENTS.forEach(function(name) {
2311
+ if (placement === null && options && options[name] !== undefined) {
2312
+ placement = name;
2313
+ }
2314
+ });
2315
+
2316
+ if (placement === null) { return node; }
2317
+
2318
+ var target = $(options[placement]);
2319
+ var parent = (placement === 'appendTo' || placement === 'prependTo')
2320
+ ? target
2321
+ : target.parent();
2322
+
2323
+ var add = function() {
2324
+ return addNode(kind, node, function() {
2325
+ node[placement](options[placement]);
2326
+ }, { parent: parent, source: 'api' });
2327
+ };
2328
+
2329
+ if (operationDepth > 0) {
2330
+ defer(add);
2331
+ return node;
2332
+ }
2333
+
2334
+ return add();
2335
+ }
2336
+
2337
+ /**
2338
+ * A row, optionally with columns in it: createRow([8, 4]).
2339
+ */
2340
+ function apiCreateRow(layout, options) {
2341
+ var row = createRow();
2342
+
2343
+ if (layout !== undefined && !Array.isArray(layout)) {
2344
+ warn('createRow: the layout is an array of column sizes, as in [8, 4]. ' +
2345
+ 'Making an empty row instead.');
2346
+ layout = [];
2347
+ }
2348
+
2349
+ (layout || []).forEach(function(size) {
2350
+ createColumn(size).appendTo(row);
2351
+ });
2352
+
2353
+ return place(row, 'row', options);
2354
+ }
2355
+
2356
+ /**
2357
+ * A column of `size` units, optionally holding `options.content`.
2358
+ */
2359
+ function apiCreateColumn(size, options) {
2360
+ options = options || {};
2361
+
2362
+ if (typeof size != 'number') {
2363
+ warn('createColumn: no column size given, using ' + MAX_COL_SIZE);
2364
+ size = MAX_COL_SIZE;
2365
+ }
2366
+
2367
+ var column = createColumn(size, options.offset);
2368
+ if (options.content !== undefined) {
2369
+ column.find('.ge-content').html(options.content);
2370
+ }
2371
+
2372
+ return place(column, 'column', options);
2373
+ }
2374
+
2375
+ /**
2376
+ * A container of the given type, with its panes already in it.
2377
+ */
2378
+ function apiCreateContainer(type, options) {
2379
+ options = options || {};
2380
+
2381
+ var definition = CONTAINERS[type];
2382
+ if (!definition) {
2383
+ warn('createContainer: no such container type "' + type + '"');
2384
+ return null;
2385
+ }
2386
+
2387
+ return place(definition.create(options), type, options);
2388
+ }
2389
+
2390
+ /** A pane appended to a container, through the add events. */
2391
+ function addPaneTo(container, type, options) {
2392
+ container = $(container);
2393
+ options = options || {};
2394
+
2395
+ var definition = CONTAINERS[containerTypeOf(container)];
2396
+
2397
+ if (!definition || containerTypeOf(container) !== type) {
2398
+ warn('this is not a ' + type + ' container');
2399
+ return null;
2400
+ }
2401
+
2402
+ var pane = definition.addPane(container, options);
2403
+
2404
+ return addNode(definition.paneKind, pane, function() {}, {
2405
+ parent: container,
2406
+ source: 'api',
2407
+ container: container,
2408
+ });
2409
+ }
2410
+
2411
+ /**
2412
+ * Host markup wrapped as a grid-editor element (spec 4.5). What is
2413
+ * inside stays the host's; grid-editor owns the wrapper only.
2414
+ */
2415
+
2416
+ /**
2417
+ * A shallow frozen copy of the settings for the instance handle, so a
2418
+ * host can read what the editor is running with without changing it
2419
+ * behind the editor's back. Arrays are copied; the objects inside them
2420
+ * are the host's own and stay shared.
2421
+ */
2422
+ function settingsCopy() {
2423
+ var copy = {};
2424
+
2425
+ $.each(settings, function(key, value) {
2426
+ copy[key] = Array.isArray(value) ? value.slice() : value;
2427
+ });
2428
+
2429
+ return Object.freeze(copy);
2430
+ }
2431
+
2432
+ /**
2433
+ * A column sized for the current view: one tier, or every tier in the
2434
+ * all view. `offset` indents it, within the same 12 unit budget.
2435
+ */
2436
+ function createColumn(size, offset) {
2437
+ var rte = getRTE(settings.content_types[0]);
2438
+ var column = $('<div class="column"/>')
2439
+ .append(createDefaultContentWrapper().html(rte ? rte.initialContent : ''))
2440
+ ;
2441
+
2442
+ tiersFor(curView).forEach(function(tier) {
2443
+ var wanted = clamp({ size: size, offset: offset || 0, leading: 'offset' });
2444
+
2445
+ setSize(column, tier, wanted.size === null ? size : wanted.size);
2446
+ setOffset(column, tier, wanted.offset);
2447
+ });
2448
+
2449
+ return column;
2450
+ }
2451
+
2452
+ /**
2453
+ * Run custom content filter on init and deinit
2454
+ */
2455
+ function runFilter(isInit) {
2456
+ if (settings.custom_filter.length) {
2457
+ $.each(settings.custom_filter, function(key, func) {
2458
+ if (typeof func == 'string') {
2459
+ func = window[func];
2460
+ }
2461
+
2462
+ func(canvas, isInit);
2463
+ });
2464
+ }
2465
+ }
2466
+
2467
+ /**
2468
+ * Wrap column content in <div class="ge-content"> where neccesary
2469
+ */
2470
+ function wrapContent() {
2471
+ canvas.find('.column').each(function() {
2472
+ var col = $(this);
2473
+ var contents = $();
2474
+
2475
+ col.children().each(function() {
2476
+ var child = $(this);
2477
+
2478
+ // The editor's own furniture is not content and not a
2479
+ // boundary either. jQuery UI's resize handle used to be
2480
+ // treated as content and wrapped into a content area of
2481
+ // its own on the next init.
2482
+ if (child.is('.ge-tools-drawer, .ui-resizable-handle')) { return; }
2483
+
2484
+ // A container sits in the column beside the content
2485
+ // areas, not inside one, so it ends a run of loose
2486
+ // content rather than joining it
2487
+ if (child.is('.row, .ge-content, [data-ge-container]')) {
2488
+ contents = doWrap(contents);
2489
+ } else {
2490
+ contents = contents.add(child);
2491
+ }
2492
+ });
2493
+
2494
+ doWrap(contents);
2495
+ });
2496
+ }
2497
+
2498
+ /**
2499
+ * Wrap a run of loose column content in a content area, and hand back
2500
+ * an empty set: the caller has to forget what it just wrapped, or the
2501
+ * next boundary wraps the same nodes again and leaves the first
2502
+ * wrapper behind, empty.
2503
+ */
2504
+ function doWrap(contents) {
2505
+ if (contents.length) {
2506
+ var contentArea = createDefaultContentWrapper().insertAfter(contents.last());
2507
+ contents.appendTo(contentArea);
2508
+ }
2509
+
2510
+ return $();
2511
+ }
2512
+
2513
+ function createDefaultContentWrapper() {
2514
+ return $('<div/>')
2515
+ .addClass('ge-content ge-content-type-' + settings.content_types[0])
2516
+ .attr('data-ge-content-type', settings.content_types[0])
2517
+ ;
2518
+ }
2519
+
2520
+ /**
2521
+ * Constrain the canvas to the view's preview width and make that
2522
+ * tier's classes the effective ones. The all view constrains nothing:
2523
+ * every tier is live, which is how the page will really render.
2524
+ */
2525
+ function switchLayout(view) {
2526
+ curView = view;
2527
+
2528
+ VIEW_KEYS.forEach(function(key) {
2529
+ canvas.toggleClass('ge-layout-' + key, key === view);
2530
+ });
2531
+ layoutDropdown.find('button').text(t(labelKeyFor(view)));
2532
+ }
2533
+
2534
+ /**
2535
+ * The view key a caller asked for, or null. 2.x callers passed a
2536
+ * layout mode index, which still works and says so once.
2537
+ */
2538
+ function viewKey(view) {
2539
+ if (typeof view == 'number') {
2540
+ warnOnceHere('changeView-index', 'changeView(' + view + '): layout modes are ' +
2541
+ 'identified by breakpoint key now, so pass one of ' +
2542
+ JSON.stringify(VIEW_KEYS) + '. Numeric indexes still work, ' +
2543
+ 'but they mean what they meant in 2.x (' +
2544
+ LEGACY_VIEW_INDEXES.join(', ') + ') and will be dropped.');
2545
+ return LEGACY_VIEW_INDEXES[view] || null;
2546
+ }
2547
+
2548
+ return VIEW_KEYS.indexOf(view) === -1 ? null : view;
2549
+ }
2550
+
2551
+ function changeView(view) {
2552
+ var key = viewKey(view);
2553
+
2554
+ if (key === null) {
2555
+ warn('changeView(' + JSON.stringify(view) + '): no such layout mode');
2556
+ return;
2557
+ }
2558
+
2559
+ switchLayout(key);
2560
+ }
2561
+
2562
+ function getView() {
2563
+ return curView;
2564
+ }
2565
+
2566
+ function getRTE(type) {
2567
+ return $.fn.gridEditor.RTEs[type];
2568
+ }
2569
+
2570
+ /**
2571
+ * The instance handle, documented API as of 3.0: the methods the
2572
+ * plugin dispatches, plus the settings and the canvas. A host holding
2573
+ * this can call several methods without dispatching each one.
2574
+ */
2575
+ var handle = {
2576
+ getHtml: getHtml,
2577
+ // init and reset are deferred when a handler calls them, so an
2578
+ // operation in flight finishes before the canvas is rebuilt
2579
+ init: function() { defer(init); },
2580
+ reset: function() { defer(reset); },
2581
+ deinit: deinit,
2582
+ destroy: destroy,
2583
+ remove: deprecatedRemove,
2584
+ changeView: changeView,
2585
+ getView: getView,
2586
+ createRow: apiCreateRow,
2587
+ createColumn: apiCreateColumn,
2588
+ createElement: function(content, options) {
2589
+ if (!featureMethods.createElement) {
2590
+ warnOnceHere('plugin:elements', 'createElement needs the elements plugin: ' +
2591
+ 'include dist/plugins/grideditor.elements.js after the editor');
2592
+ return null;
2593
+ }
2594
+
2595
+ return featureMethods.createElement(content, options);
2596
+ },
2597
+ createContainer: apiCreateContainer,
2598
+ addTab: function(container, options) { return addPaneTo(container, 'tabs', options); },
2599
+ addAccordionItem: function(container, options) {
2600
+ return addPaneTo(container, 'accordion', options);
2601
+ },
2602
+ setLocale: setLocale,
2603
+ canvas: canvas,
2604
+ settings: settingsCopy(),
2605
+ };
2606
+
2607
+ // Methods a later phase fills in: registered, so calling one gets a
2608
+ // warning and null rather than silence.
2609
+ $.each(METHODS, function(name, descriptor) {
2610
+ if (!descriptor.unimplemented) { return; }
2611
+
2612
+ handle[name] = function() {
2613
+ warnOnceHere(name, name + ' is registered but not implemented in this build yet');
2614
+ return null;
2615
+ };
2616
+ });
2617
+
2618
+ baseElem.data('grideditor', handle);
2619
+
2620
+ loadPlugins();
2621
+ setup();
2622
+ init();
2623
+
2624
+ });
2625
+
2626
+ return self;
2627
+
2628
+ };
2629
+
2630
+ $.fn.gridEditor.RTEs = {};
2631
+
2632
+ /**
2633
+ * Container plugins: tabs, accordions, popups, and whatever a host writes.
2634
+ *
2635
+ * A plugin is a factory registered under the type it builds, called once per
2636
+ * editor with the handle described in docs/plugins.md. Loading its file is
2637
+ * what makes the type available; the `plugins` setting narrows that list.
2638
+ *
2639
+ * $.fn.gridEditor.containers.carousel = function(ge) {
2640
+ * return { labelKey: ..., create: ..., mark: ..., unmark: ... };
2641
+ * };
2642
+ */
2643
+ $.fn.gridEditor.containers = {};
2644
+
2645
+ /**
2646
+ * Feature plugins: a piece of the editor that is not a container type, in a
2647
+ * file of its own. Element level controls are one. Same bargain as a
2648
+ * container plugin - a factory under its name, called once per editor with
2649
+ * the handle in docs/plugins.md - and the same `plugins` setting decides
2650
+ * which of the loaded ones are used.
2651
+ */
2652
+ $.fn.gridEditor.features = {};
2653
+
2654
+ /** Translator for the editor integrations, which get settings and no instance. */
2655
+ $.fn.gridEditor.t = translate;
2656
+
2657
+ /**
2658
+ * Locale registry: code -> { key: string }.
2659
+ *
2660
+ * English is built in rather than shipped as a file, because it is where every
2661
+ * lookup ends: a page that loads no locale file still has a complete UI.
2662
+ * Removing a key from it is a breaking change. Locale files register
2663
+ * themselves here, see src/js/locales/.
2664
+ *
2665
+ * Every key is listed in docs/locale-keys.md, which test/locales.js holds to
2666
+ * this catalogue in both directions.
2667
+ */
2668
+ $.fn.gridEditor.locales = {
2669
+ en: {
2670
+ 'tool.move': 'Move',
2671
+ 'tool.settings': 'Settings',
2672
+ 'tool.add_row': 'Add row',
2673
+ 'tool.add_column': 'Add column\n(hold to choose the width)',
2674
+ 'tool.column_size': '{size} of 12',
2675
+ 'tool.delete_row': 'Remove row',
2676
+ 'tool.delete_column': 'Remove col',
2677
+ 'tool.delete_container': 'Remove container',
2678
+ 'tool.delete_pane': 'Remove pane',
2679
+ 'tool.rename': 'Double click to rename',
2680
+ 'tool.column_narrower': 'Make column narrower\n(hold shift for min)',
2681
+ 'tool.column_wider': 'Make column wider\n(hold shift for max)',
2682
+ 'tool.indent_decrease': 'Decrease indent\n(hold shift for none)',
2683
+ 'tool.indent_increase': 'Increase indent\n(hold shift for max)',
2684
+ 'tool.edit_source': 'Edit Source Code',
2685
+ 'tool.preview': 'Preview',
2686
+ 'tool.id_placeholder': 'id',
2687
+ 'tool.id_title': 'Set a unique identifier',
2688
+ 'tool.classes_placeholder': 'classes',
2689
+ 'tool.classes_title': 'Css classes, separated by spaces',
2690
+ 'tool.toggle_class': 'Toggle "{label}" styling',
2691
+ 'row.add': 'Add row {layout}',
2692
+ 'confirm.title': 'Confirm',
2693
+ 'confirm.ok': 'Delete',
2694
+ 'confirm.cancel': 'Cancel',
2695
+ 'confirm.delete_row': 'Delete row?',
2696
+ 'confirm.delete_column': 'Delete column?',
2697
+ 'confirm.delete_container': 'Delete this container and everything in it?',
2698
+ 'view.all': 'All sizes',
2699
+ 'view.xs': 'Phone',
2700
+ 'view.sm': 'Tablet',
2701
+ 'view.md': 'Small desktop',
2702
+ 'view.lg': 'Desktop',
2703
+ 'view.xl': 'Large desktop',
2704
+ 'view.xxl': 'Widescreen',
2705
+ 'error.tinymce_missing': 'tinyMCE not available! Make sure you loaded the tinyMCE js file.',
2706
+ 'error.ckeditor_missing': 'CKEditor not available! Make sure you loaded the ckeditor and jquery adapter js files.',
2707
+ 'error.summernote_missing': 'Summernote not available! Make sure you loaded the Summernote js file.',
2708
+ },
2709
+ };
2710
+
2711
+ })( jQuery );