@themarioga/grid-editor 4.1.0 → 5.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 (42) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +131 -10
  3. package/UPGRADING.md +55 -0
  4. package/dist/grideditor.css +466 -0
  5. package/dist/grideditor.min.css +1 -1
  6. package/dist/grideditor.min.css.map +1 -1
  7. package/dist/jquery.grideditor.bundle.min.js +2 -2
  8. package/dist/jquery.grideditor.js +741 -126
  9. package/dist/jquery.grideditor.min.js +1 -1
  10. package/dist/jquery.grideditor.min.js.map +1 -1
  11. package/dist/locales/grideditor.es.js +30 -0
  12. package/dist/locales/grideditor.es.min.js +1 -1
  13. package/dist/locales/grideditor.es.min.js.map +1 -1
  14. package/dist/plugins/grideditor.clipboard.js +274 -0
  15. package/dist/plugins/grideditor.clipboard.min.js +2 -0
  16. package/dist/plugins/grideditor.clipboard.min.js.map +1 -0
  17. package/dist/plugins/grideditor.float.js +56 -0
  18. package/dist/plugins/grideditor.float.min.js +2 -0
  19. package/dist/plugins/grideditor.float.min.js.map +1 -0
  20. package/dist/plugins/grideditor.sections.js +199 -0
  21. package/dist/plugins/grideditor.sections.min.js +2 -0
  22. package/dist/plugins/grideditor.sections.min.js.map +1 -0
  23. package/dist/plugins/grideditor.textalign.js +60 -0
  24. package/dist/plugins/grideditor.textalign.min.js +2 -0
  25. package/dist/plugins/grideditor.textalign.min.js.map +1 -0
  26. package/docs/events.md +17 -3
  27. package/docs/locale-keys.md +49 -1
  28. package/docs/plugins.md +45 -3
  29. package/example/autocols.html +137 -0
  30. package/example/breakpoints.html +7 -5
  31. package/example/clipboard.html +99 -0
  32. package/example/index.html +24 -2
  33. package/example/utilities.html +22 -1
  34. package/example/wrap_content.html +66 -16
  35. package/package.json +1 -1
  36. package/src/js/jquery.grideditor.js +742 -127
  37. package/src/js/locales/grideditor.es.js +30 -0
  38. package/src/js/plugins/grideditor.clipboard.js +274 -0
  39. package/src/js/plugins/grideditor.float.js +56 -0
  40. package/src/js/plugins/grideditor.sections.js +199 -0
  41. package/src/js/plugins/grideditor.textalign.js +60 -0
  42. package/src/less/grideditor.less +138 -0
@@ -18,6 +18,7 @@
18
18
  */
19
19
  var METHODS = {
20
20
  getHtml: { value: true, noInstance: function(element) { return element.html(); } },
21
+ getPlainHtml: { value: true, noInstance: function(element) { return plainHtml(element.html()); } },
21
22
  init: {},
22
23
  deinit: {},
23
24
  reset: {},
@@ -28,6 +29,7 @@ var METHODS = {
28
29
  createRow: { value: true },
29
30
  createColumn: { value: true },
30
31
  createElement: { value: true },
32
+ createSection: { value: true },
31
33
  createContainer: { value: true },
32
34
  addTab: { value: true },
33
35
  addAccordionItem: { value: true },
@@ -62,8 +64,8 @@ var BREAKPOINTS = [
62
64
 
63
65
  /**
64
66
  * The view that edits every tier at once. It is the default, and the one most
65
- * pages want: a layout that needs no per-device tuning is written once and
66
- * lands on all six prefixes.
67
+ * pages want: a layout that needs no per-device tuning is written once, as the
68
+ * class with no breakpoint, and applies at every size.
67
69
  */
68
70
  var ALL_VIEW = 'all';
69
71
  var ALL_VIEW_LABEL_KEY = 'view.all';
@@ -77,6 +79,31 @@ var LEGACY_VIEW_INDEXES = ['lg', 'sm', 'xs'];
77
79
  var MAX_COL_SIZE = 12;
78
80
  var MAX_COL_OFFSET = 11;
79
81
 
82
+ /**
83
+ * The two sizes that are not a number of units: `equal` is Bootstrap's col,
84
+ * which shares what the row has left with the other equal columns, and
85
+ * `auto` is col-auto, as wide as its content.
86
+ */
87
+ var FLEX_SIZES = ['equal', 'auto'];
88
+
89
+ /** How many columns a row-cols class puts on a line. */
90
+ var ROW_COLS_VALUES = ['1', '2', '3', '4', '5', '6', 'auto'];
91
+
92
+ function rowColsClass(tier, value) {
93
+ return 'row-cols' + (tier.infix ? '-' + tier.infix : '') + '-' + value;
94
+ }
95
+
96
+ function isUnits(size) {
97
+ return typeof size === 'number';
98
+ }
99
+
100
+ /** The class that gives a column `size` at one tier: col-md-4, col-md, col-md-auto. */
101
+ function sizeClass(tier, size) {
102
+ if (size === 'equal') { return tier.infix ? 'col-' + tier.infix : 'col'; }
103
+
104
+ return tier.colPrefix + size;
105
+ }
106
+
80
107
  function breakpoint(key) {
81
108
  for (var i = 0; i < BREAKPOINTS.length; i++) {
82
109
  if (BREAKPOINTS[i].key === key) { return BREAKPOINTS[i]; }
@@ -84,9 +111,12 @@ function breakpoint(key) {
84
111
  return null;
85
112
  }
86
113
 
87
- /** The tiers a view writes to: one, or all six in the all view. */
114
+ /**
115
+ * The tier a view writes to. The all view writes the base class, the one
116
+ * with no breakpoint, and takes the others off.
117
+ */
88
118
  function tiersFor(view) {
89
- if (view === ALL_VIEW) { return BREAKPOINTS.slice(); }
119
+ if (view === ALL_VIEW) { return [BREAKPOINTS[0]]; }
90
120
 
91
121
  var tier = breakpoint(view);
92
122
  return tier ? [tier] : [];
@@ -218,6 +248,56 @@ function dispatch(set, name, args) {
218
248
  return set;
219
249
  }
220
250
 
251
+ /**
252
+ * Html with grid-editor's own marking taken off: the ge-* classes, the
253
+ * column class and the data-ge-* attributes. A div that was only there for
254
+ * the editor - a content area, a bare element or container wrapper - is left
255
+ * with nothing on it, and goes, its children taking its place.
256
+ *
257
+ * The marking is what lets the editor read its markup back, so this is for
258
+ * publishing, not for saving something to edit again.
259
+ */
260
+ function plainHtml(html) {
261
+ // Parsed in a document of its own, which is inert: no script in the
262
+ // markup runs and no image starts loading while it is being cleaned
263
+ var root = $(document.implementation.createHTMLDocument('').body);
264
+ root[0].innerHTML = html;
265
+ var emptied = [];
266
+
267
+ root.find('*').each(function() {
268
+ var node = $(this);
269
+ var marked = false;
270
+
271
+ $.each($.makeArray(this.attributes), function(i, attribute) {
272
+ if (attribute.name.indexOf('data-ge-') === 0) {
273
+ node.removeAttr(attribute.name);
274
+ marked = true;
275
+ }
276
+ });
277
+
278
+ var classes = (node.attr('class') || '').split(/\s+/).filter(Boolean);
279
+ var kept = classes.filter(function(name) {
280
+ return name !== 'column' && name.indexOf('ge-') !== 0;
281
+ });
282
+ if (kept.length !== classes.length) { marked = true; }
283
+ if (kept.length) {
284
+ node.attr('class', kept.join(' '));
285
+ } else {
286
+ node.removeAttr('class');
287
+ }
288
+
289
+ if (marked && this.tagName === 'DIV' && !this.attributes.length) {
290
+ emptied.push(this);
291
+ }
292
+ });
293
+
294
+ emptied.forEach(function(div) {
295
+ $(div).replaceWith($(div).contents());
296
+ });
297
+
298
+ return root[0].innerHTML;
299
+ }
300
+
221
301
  $.fn.gridEditor = function( optionsOrMethod ) {
222
302
 
223
303
  var self = this;
@@ -264,11 +344,12 @@ $.fn.gridEditor = function( optionsOrMethod ) {
264
344
  'tab_tools' : [], // Host tools on tab drawers
265
345
  'accordion_tools' : [], // Host tools on accordion item drawers
266
346
  'plugins' : null, // Plugins to use, of any kind; null means every one loaded
347
+ 'row_cols' : true, // A row's "columns per row" field, row-cols-*
267
348
  'utilities' : {}, // Options for the utility plugins, by plugin name
268
349
  'elements' : NESTED_SETTINGS.elements, // Element level controls, below the column
269
350
  'custom_filter' : '',
270
351
  'content_types' : ['tinymce'],
271
- 'valid_col_sizes' : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
352
+ 'valid_col_sizes' : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 'equal', 'auto'],
272
353
  'valid_col_offsets' : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
273
354
  'add_column' : NESTED_SETTINGS.add_column, // The add column tool
274
355
  'layout_modes' : VIEW_KEYS.slice(), // Which views the dropdown offers
@@ -580,54 +661,91 @@ $.fn.gridEditor = function( optionsOrMethod ) {
580
661
  }
581
662
 
582
663
  /**
583
- * Resize a column through the events, writing the tiers the current
584
- * view covers: one in a per-breakpoint view, all six in the all view.
664
+ * What resizing this column to `size` in `view` would write, or null
665
+ * when the budget refuses it or there is nothing to change.
585
666
  *
586
- * The budget is checked per tier and the whole change is refused if
587
- * any tier has no room, so a resize never half lands.
588
- */
589
- /**
590
- * What resizing this column to `size` would write, or null when the
591
- * budget refuses it or there is nothing to change.
667
+ * A breakpoint view writes its own tier. The all view writes the base
668
+ * class and takes the size off every other tier, so one size is what
669
+ * applies everywhere; the plan says what it took off.
592
670
  *
593
671
  * Refused rather than quietly clamped: the offset is something the
594
- * user set, and a tool that rewrites it is a tool that lies. The plan
595
- * covers every tier the view writes, so a resize never half lands.
672
+ * user set, and a tool that rewrites it is a tool that lies. In the
673
+ * all view the base size has to fit every tier's offset, since it is
674
+ * the size at every tier once the others are gone.
596
675
  */
597
- function planSize(col, size) {
598
- var tiers = tiersFor(curView);
599
- var wanted = tiers.map(function(tier) {
600
- return clamp({ size: size, offset: getEffectiveOffset(col, tier) || 0 });
676
+ function planSize(col, size, view) {
677
+ view = view || curView;
678
+
679
+ if (view !== ALL_VIEW) {
680
+ var tier = breakpoint(view);
681
+ var wanted = clamp({ size: size, offset: getEffectiveOffset(col, tier) || 0 });
682
+
683
+ if (wanted.refused || getSize(col, tier) === wanted.size) { return null; }
684
+
685
+ return { writes: [{ tier: tier, size: wanted.size }], cleared: null, size: wanted.size };
686
+ }
687
+
688
+ var refused = BREAKPOINTS.some(function(each) {
689
+ return clamp({ size: size, offset: getEffectiveOffset(col, each) || 0 }).refused;
601
690
  });
691
+ if (refused) { return null; }
602
692
 
603
- if (wanted.some(function(request) { return request.refused; })) { return null; }
693
+ var base = BREAKPOINTS[0];
694
+ var writes = [];
695
+ var cleared = [];
604
696
 
605
- var unchanged = tiers.every(function(tier, i) {
606
- return getSize(col, tier) === wanted[i].size;
697
+ BREAKPOINTS.slice(1).forEach(function(each) {
698
+ var own = getSize(col, each);
699
+ if (own === null) { return; }
700
+
701
+ writes.push({ tier: each, size: null });
702
+ cleared.push({ breakpoint: each.key, value: own });
607
703
  });
608
- if (unchanged) { return null; }
609
704
 
610
- return { tiers: tiers, sizes: wanted, size: wanted[0].size };
705
+ if (getSize(col, base) !== size) { writes.unshift({ tier: base, size: size }); }
706
+
707
+ return writes.length ? { writes: writes, cleared: cleared, size: size } : null;
611
708
  }
612
709
 
613
710
  function writeSize(col, plan) {
614
- plan.tiers.forEach(function(tier, i) { setSize(col, tier, plan.sizes[i].size); });
711
+ plan.writes.forEach(function(write) { setSize(col, write.tier, write.size); });
615
712
  stripPixelWidths(col);
616
713
  }
617
714
 
618
- /** Resize a column from a tool, announcing it either side. */
619
- function resizeColumn(col, size, source) {
715
+ /** The all view's payloads say what the write took off the breakpoints. */
716
+ function withCleared(extra, cleared) {
717
+ if (cleared) { extra.cleared = cleared; }
718
+ return extra;
719
+ }
720
+
721
+ /**
722
+ * Resize a column, announcing it either side. `size` is units,
723
+ * `equal` or `auto`; `view` is the current one unless the size field
724
+ * of a plugin's panel says otherwise.
725
+ */
726
+ function resizeColumn(col, size, source, view) {
727
+ view = view || curView;
728
+
620
729
  var from = currentSize(col);
621
- var plan = planSize(col, size);
730
+ var plan = planSize(col, size, view);
622
731
 
623
732
  if (!plan) { return false; }
624
733
 
625
734
  return operate(function() {
626
- var payload = payloadFor('column', col, { source: source, from: from, to: plan.size });
735
+ var payload = payloadFor('column', col, withCleared({
736
+ source: source,
737
+ breakpoint: view,
738
+ from: from,
739
+ to: plan.size,
740
+ }, plan.cleared));
627
741
 
628
- if (!emit('before-resize', payload)) { return false; }
742
+ if (!emit('before-resize', payload)) {
743
+ refreshUtilities(col);
744
+ return false;
745
+ }
629
746
 
630
747
  writeSize(col, plan);
748
+ refreshUtilities(col);
631
749
  emit('after-resize', payload);
632
750
 
633
751
  return true;
@@ -637,41 +755,49 @@ $.fn.gridEditor = function( optionsOrMethod ) {
637
755
  /**
638
756
  * Indent a column, shrinking it when the budget needs it: the offset
639
757
  * is what the user asked for, so it is the one that gets its way.
758
+ *
759
+ * Like a size, an offset is written to the tier being edited, or in
760
+ * the all view as the base class with every other tier's taken off.
640
761
  */
641
762
  function indentColumn(col, offset, source) {
642
- var tiers = tiersFor(curView);
763
+ var everywhere = curView === ALL_VIEW;
764
+ var tier = everywhere ? BREAKPOINTS[0] : breakpoint(curView);
643
765
  var from = currentOffset(col);
644
766
 
645
767
  offset = Math.min(Math.max(offset, 0), MAX_COL_OFFSET);
646
768
 
647
- var unchanged = tiers.every(function(tier) {
648
- return (getOffset(col, tier) || 0) === offset;
649
- });
650
- if (unchanged) { return false; }
769
+ var others = everywhere ? BREAKPOINTS.slice(1).filter(function(each) {
770
+ return getOffset(col, each) !== null;
771
+ }) : [];
772
+
773
+ if ((getOffset(col, tier) || 0) === offset && !others.length) { return false; }
651
774
 
652
775
  return operate(function() {
653
- var payload = payloadFor('column', col, {
776
+ var payload = payloadFor('column', col, withCleared({
654
777
  source: source,
655
778
  from: from,
656
779
  to: offset,
657
- });
780
+ }, everywhere ? others.map(function(each) {
781
+ return { breakpoint: each.key, value: getOffset(col, each) };
782
+ }) : null));
658
783
 
659
784
  if (!emit('before-indent', payload)) { return false; }
660
785
 
661
- tiers.forEach(function(tier) {
662
- var was = getEffectiveSize(col, tier);
663
- var wanted = clamp({ size: was, offset: offset, leading: 'offset' });
786
+ setOffset(col, tier, offset);
787
+ others.forEach(function(each) { setOffset(col, each, 0); });
664
788
 
665
- setOffset(col, tier, wanted.offset);
789
+ // Wherever the column's size and the new offset no longer fit
790
+ // - one tier, or every tier in the all view - the size gives
791
+ // way, at that tier only. An equal or auto column fits anywhere.
792
+ (everywhere ? BREAKPOINTS : [tier]).forEach(function(each) {
793
+ var was = getEffectiveSize(col, each);
794
+ if (!isUnits(was)) { return; }
666
795
 
667
- // Only where the budget actually forced the column to give
668
- // way; otherwise an indent would write size classes for
669
- // tiers nobody asked it to touch
670
- if (wanted.size !== null && wanted.size !== was) {
671
- setSize(col, tier, wanted.size);
672
- }
796
+ var wanted = clamp({ size: was, offset: offset, leading: 'offset' });
797
+ if (wanted.size !== was) { setSize(col, each, wanted.size); }
673
798
  });
674
799
 
800
+ refreshUtilities(col);
675
801
  emit('after-indent', payload);
676
802
 
677
803
  return true;
@@ -770,17 +896,17 @@ $.fn.gridEditor = function( optionsOrMethod ) {
770
896
  addRowGroup = $('<div class="ge-addRowGroup btn-group" />').appendTo(wrapper);
771
897
  addContainerGroup = $('<div class="ge-addContainerGroup btn-group" />');
772
898
  $.each(settings.new_row_layouts, function(j, layout) {
899
+ var grouped = !Array.isArray(layout);
773
900
  var btn = $('<a class="btn btn-sm btn-primary" />')
774
- .attr('title', t('row.add', { layout: layout.join('-') }))
901
+ .attr('title', grouped
902
+ ? t('row.add_row_cols', { columns: layout.columns, counts: rowColsText(layout.row_cols) })
903
+ : t('row.add', { layout: layout.join('-') }))
775
904
  // What this button makes, in the markup rather than in
776
905
  // jQuery data: a drag works on a clone of it
777
906
  .attr('data-ge-toolbar', 'row')
778
- .attr('data-ge-layout', layout.join(','))
907
+ .attr('data-ge-layout', grouped ? JSON.stringify(layout) : layout.join(','))
779
908
  .on('click', function() {
780
- var row = createRow();
781
- layout.forEach(function(i) {
782
- createColumn(i).appendTo(row);
783
- });
909
+ var row = rowFromLayoutValue(layout);
784
910
 
785
911
  var added = addNode('row', row, function() {
786
912
  row.appendTo(canvas);
@@ -795,10 +921,14 @@ $.fn.gridEditor = function( optionsOrMethod ) {
795
921
 
796
922
  btn.append('<i class="bi bi-plus"></i>');
797
923
 
798
- var layoutName = layout.join(' - ');
924
+ // A row of columns shares out the icon as they share the row;
925
+ // a row with row-cols draws one line of its widest count
926
+ var sizes = grouped ? rowColsIcon(layout) : layout;
799
927
  var icon = '<div class="row ge-row-icon">';
800
- layout.forEach(function(i) {
801
- icon += '<div class="column col-' + i + '"/>';
928
+ sizes.forEach(function(size) {
929
+ // Closed explicitly: jQuery 4 no longer expands <div/>, and
930
+ // each column would be parsed inside the one before
931
+ icon += '<div class="column ' + sizeClass(BREAKPOINTS[0], size) + '"></div>';
802
932
  });
803
933
  icon += '</div>';
804
934
  btn.append(icon);
@@ -830,6 +960,29 @@ $.fn.gridEditor = function( optionsOrMethod ) {
830
960
  ;
831
961
  });
832
962
 
963
+ // A feature plugin's own buttons, beside the containers': what
964
+ // one makes goes onto the canvas, where the plugin says it belongs
965
+ $.each(FEATURES, function(name, feature) {
966
+ (feature.toolbar || []).forEach(function(item, index) {
967
+ $('<a class="btn btn-sm btn-primary ge-add-container ge-add-feature" />')
968
+ .attr('title', t(item.labelKey))
969
+ .attr('data-ge-toolbar', 'feature')
970
+ .attr('data-ge-feature', name)
971
+ .attr('data-ge-item', index)
972
+ .append($('<i />').addClass(item.iconClass || 'bi bi-plus'))
973
+ .append($('<span />').text(t(item.labelKey)))
974
+ .on('click', function() {
975
+ var made = item.create();
976
+
977
+ addNode(item.kind, made, function() {
978
+ made.appendTo(canvas);
979
+ }, { parent: canvas, source: item.source || 'tool' });
980
+ })
981
+ .appendTo(addContainerGroup)
982
+ ;
983
+ });
984
+ });
985
+
833
986
  // Buttons on right
834
987
  layoutDropdown = $('<div class="dropdown pull-right ge-layout-mode">' +
835
988
  '<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown"></button>' +
@@ -1005,14 +1158,14 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1005
1158
 
1006
1159
  if (!under) { return null; }
1007
1160
 
1008
- var region = $(under).closest('.column, .ge-canvas');
1161
+ var region = $(under).closest(['.column', '.ge-canvas'].concat(pluginHooks('regions')).join(', '));
1009
1162
  if (!region.length || (region[0] !== canvas[0] && !canvas[0].contains(region[0]))) {
1010
1163
  return null;
1011
1164
  }
1012
1165
 
1013
1166
  var before = null;
1014
1167
 
1015
- region.children('.row, .ge-content, [data-ge-container]').each(function() {
1168
+ region.children(blockSelector()).each(function() {
1016
1169
  if (before) { return; }
1017
1170
 
1018
1171
  var box = this.getBoundingClientRect();
@@ -1022,6 +1175,30 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1022
1175
  return { region: region, before: before };
1023
1176
  }
1024
1177
 
1178
+ /**
1179
+ * A feature's toolbar button dropped on the canvas. A region that will
1180
+ * not have what it makes - a section dropped on a column - gives way
1181
+ * to the canvas, just after the top level block the pointer was in.
1182
+ */
1183
+ function insertFeatureFromToolbar(button, where) {
1184
+ var item = FEATURES[button.attr('data-ge-feature')].toolbar[parseInt(button.attr('data-ge-item'), 10)];
1185
+ var made = item.create();
1186
+
1187
+ if (!acceptsBlock(where.region, made)) {
1188
+ var top = where.region.parentsUntil(canvas).addBack().first();
1189
+ where = { region: canvas, before: top.length ? top.next() : null };
1190
+ if (where.before && !where.before.length) { where.before = null; }
1191
+ }
1192
+
1193
+ return addNode(item.kind, made, function() {
1194
+ if (where.before) {
1195
+ made.insertBefore(where.before);
1196
+ } else {
1197
+ made.appendTo(where.region);
1198
+ }
1199
+ }, { parent: where.region, source: item.source || 'dragdrop' });
1200
+ }
1201
+
1025
1202
  /** A line where the block would go, following the pointer. */
1026
1203
  function showDropMarker(pageX, pageY) {
1027
1204
  var where = dropPlaceAt(pageX, pageY);
@@ -1048,6 +1225,10 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1048
1225
  * the pointer left it.
1049
1226
  */
1050
1227
  function insertFromToolbar(button, where) {
1228
+ if (button.attr('data-ge-toolbar') === 'feature') {
1229
+ return insertFeatureFromToolbar(button, where);
1230
+ }
1231
+
1051
1232
  var container = button.attr('data-ge-toolbar') === 'container';
1052
1233
  var type = button.attr('data-ge-container-type');
1053
1234
  var made = container
@@ -1072,16 +1253,94 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1072
1253
  }, { parent: where.region, source: 'dragdrop' });
1073
1254
  }
1074
1255
 
1256
+ /**
1257
+ * The column width as a utility family, so the Responsive section of
1258
+ * a column's panel has a field for it: every size, units or not, in
1259
+ * the view being edited. A write is a resize, through the resize
1260
+ * events, not a utility change.
1261
+ */
1262
+ function widthFamily() {
1263
+ var values = [];
1264
+ for (var units = 1; units <= MAX_COL_SIZE; units++) { values.push(String(units)); }
1265
+
1266
+ return {
1267
+ name: 'col',
1268
+ values: values.concat(FLEX_SIZES),
1269
+ appliesTo: ['column'],
1270
+ labelKey: 'utility.col_width',
1271
+ className: function(key, value) { return sizeClass(breakpoint(key), parseSize(value)); },
1272
+ choices: function() { return settings.valid_col_sizes.map(String); },
1273
+ label: function(value) {
1274
+ if (value === 'equal') { return t('utility.col_equal'); }
1275
+ if (value === 'auto') { return t('utility.col_auto'); }
1276
+
1277
+ return value;
1278
+ },
1279
+ write: function(col, value, view, source) {
1280
+ return resizeColumn(col, value === null ? null : parseSize(value), source, view);
1281
+ },
1282
+ // With no size of its own, a column in a row with row-cols
1283
+ // takes its share from the row, which the field says
1284
+ blank: function(col, view) {
1285
+ var winner = view === ALL_VIEW ? null : rowColsWinner(col, breakpoint(view));
1286
+ return winner ? t('utility.col_from_row', { count: rowColsLabel(winner.value) }) : null;
1287
+ },
1288
+ };
1289
+ }
1290
+
1291
+ /** A size as markup or a select carries it: '4' is 4, 'equal' and 'auto' are themselves. */
1292
+ function parseSize(size) {
1293
+ return /^\d+$/.test(String(size)) ? parseInt(size, 10) : size;
1294
+ }
1295
+
1296
+ /** A row from a toolbar button's data-ge-layout: sizes, or a row-cols layout as JSON. */
1075
1297
  function rowFromLayout(layout) {
1298
+ if (/^\s*\{/.test(layout || '')) { return rowFromLayoutValue(JSON.parse(layout)); }
1299
+
1300
+ return rowFromLayoutValue((layout || '').split(',').filter(function(size) {
1301
+ return size !== '';
1302
+ }).map(parseSize));
1303
+ }
1304
+
1305
+ /**
1306
+ * A row from a layout: an array of sizes, or { row_cols, columns } -
1307
+ * row-cols per breakpoint, and that many columns sized by the row.
1308
+ */
1309
+ function rowFromLayoutValue(layout) {
1076
1310
  var row = createRow();
1077
1311
 
1078
- (layout || '').split(',').forEach(function(size) {
1079
- if (size !== '') { createColumn(parseInt(size, 10)).appendTo(row); }
1312
+ if (Array.isArray(layout)) {
1313
+ layout.forEach(function(size) { createColumn(size).appendTo(row); });
1314
+ return row;
1315
+ }
1316
+
1317
+ $.each(layout.row_cols || {}, function(key, value) {
1318
+ var tier = breakpoint(key);
1319
+ if (tier && ROW_COLS_VALUES.indexOf(String(value)) !== -1) { row.addClass(rowColsClass(tier, value)); }
1080
1320
  });
1081
1321
 
1322
+ for (var i = 0; i < (layout.columns || 0); i++) { createColumn(null).appendTo(row); }
1323
+
1082
1324
  return row;
1083
1325
  }
1084
1326
 
1327
+ /** "1, md: 3": a row-cols layout's counts, for its button's title. */
1328
+ function rowColsText(counts) {
1329
+ return BREAKPOINTS.filter(function(tier) { return counts && counts[tier.key] !== undefined; })
1330
+ .map(function(tier) { return (tier.infix ? tier.key + ': ' : '') + counts[tier.key]; })
1331
+ .join(', ');
1332
+ }
1333
+
1334
+ /** The icon of a row-cols layout: one line of equal columns, as many as its widest count. */
1335
+ function rowColsIcon(layout) {
1336
+ var widest = 1;
1337
+ $.each(layout.row_cols || {}, function(key, value) {
1338
+ if (value !== 'auto') { widest = Math.max(widest, parseInt(value, 10) || 1); }
1339
+ });
1340
+
1341
+ return Array.apply(null, Array(Math.min(widest, layout.columns || widest))).map(function() { return 'equal'; });
1342
+ }
1343
+
1085
1344
  function onScroll(e) {
1086
1345
  var $window = $(window);
1087
1346
 
@@ -1168,6 +1427,7 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1168
1427
  // one that rebuilt its area's DOM brought the preview styles back
1169
1428
  // with it, and the attribute recording them came back too
1170
1429
  clearPreviews(canvas);
1430
+ canvas.find('[data-ge-row-cols]').removeAttr('data-ge-row-cols');
1171
1431
  unmarkContainers();
1172
1432
  removeSortable();
1173
1433
  removeResizable();
@@ -1186,6 +1446,23 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1186
1446
  return html;
1187
1447
  }
1188
1448
 
1449
+ /**
1450
+ * One node's markup, as getHtml would give it: the canvas goes out of
1451
+ * editing to read it and comes back, as it does for getHtml.
1452
+ */
1453
+ function nodeHtml(node) {
1454
+ deinit();
1455
+ stripPixelWidths(node);
1456
+ var html = node[0].outerHTML;
1457
+ init();
1458
+ return html;
1459
+ }
1460
+
1461
+ /** getHtml with grid-editor's own classes and attributes taken off. */
1462
+ function getPlainHtml() {
1463
+ return plainHtml(getHtml());
1464
+ }
1465
+
1189
1466
  function destroy() {
1190
1467
  deinit();
1191
1468
  removeConfirmModal();
@@ -1236,6 +1513,9 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1236
1513
  */
1237
1514
  function loadPlugins() {
1238
1515
  var api = pluginApi();
1516
+
1517
+ registerFamily('grid', {}, widthFamily());
1518
+ if (settings.row_cols !== false) { registerFamily('grid', {}, rowColsFamily()); }
1239
1519
  var wanted = function(name) {
1240
1520
  return !settings.plugins || settings.plugins.indexOf(name) !== -1;
1241
1521
  };
@@ -1321,6 +1601,14 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1321
1601
  getUtility: getUtility,
1322
1602
  setUtility: setUtility,
1323
1603
  utilityField: utilityField,
1604
+ bareStyle: bareStyle,
1605
+ rowFromLayout: rowFromLayoutValue,
1606
+ nodeHtml: nodeHtml,
1607
+ toolbarItems: function(name) {
1608
+ return mainControls
1609
+ ? mainControls.find('[data-ge-toolbar="feature"][data-ge-feature="' + name + '"]')
1610
+ : $();
1611
+ },
1324
1612
  };
1325
1613
  }
1326
1614
 
@@ -1392,6 +1680,8 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1392
1680
  }
1393
1681
 
1394
1682
  function utilityClass(family, tier, value) {
1683
+ if (family.className) { return family.className(tier.key, value); }
1684
+
1395
1685
  return family.prefix + (tier.infix ? '-' + tier.infix : '') + '-' + value;
1396
1686
  }
1397
1687
 
@@ -1538,6 +1828,10 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1538
1828
  return false;
1539
1829
  }
1540
1830
 
1831
+ // A family whose writes are some other operation - the column
1832
+ // width, which is a resize - does them its own way
1833
+ if (family.write) { return family.write(node, value, view, options.source || 'api'); }
1834
+
1541
1835
  var plan = planUtility(node, family, view, value);
1542
1836
  if (!plan) { return false; }
1543
1837
 
@@ -1568,6 +1862,29 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1568
1862
  });
1569
1863
  }
1570
1864
 
1865
+ /**
1866
+ * ge.bareStyle(node, family, property): what a css property comes to
1867
+ * on a node with none of the family's classes, at any breakpoint. The
1868
+ * answer a preview needs for "no class applies here" when that is not
1869
+ * a constant: text-align inherits from the parent, and the host's own
1870
+ * css may float an element. The browser is the only one who knows, so
1871
+ * the classes come off for the moment it takes to ask.
1872
+ */
1873
+ function bareStyle(node, name, property) {
1874
+ var family = familyNamed(name, 'bareStyle');
1875
+ node = $(node).first();
1876
+ if (!family || !node.length) { return null; }
1877
+
1878
+ var original = node.attr('class');
1879
+ BREAKPOINTS.forEach(function(tier) { writeUtility(node, family, tier, null); });
1880
+
1881
+ var value = getComputedStyle(node[0]).getPropertyValue(property);
1882
+
1883
+ if (original === undefined) { node.removeAttr('class'); } else { node.attr('class', original); }
1884
+
1885
+ return value;
1886
+ }
1887
+
1571
1888
  /** Bring a node's panel and preview up to date with its classes. */
1572
1889
  function refreshUtilities(node) {
1573
1890
  var details = node.children('.ge-tools-drawer').children('.ge-details');
@@ -1598,6 +1915,8 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1598
1915
 
1599
1916
  if (curView !== ALL_VIEW) { previewTier(scope, breakpoint(curView)); }
1600
1917
 
1918
+ markRowCols(scope);
1919
+
1601
1920
  // Whatever a plugin marks the canvas with to show its utilities
1602
1921
  // goes stale at the same moments the preview does
1603
1922
  plugins('onRefresh', scope);
@@ -1615,6 +1934,8 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1615
1934
  $.extend(styles, family.preview(effectiveUtility(node, family, tier), node, kind));
1616
1935
  });
1617
1936
 
1937
+ if (kind === 'column') { $.extend(styles, rowColsPreview(node, tier)); }
1938
+
1618
1939
  // A plugin whose families settle one property between them
1619
1940
  // previews the node as a whole
1620
1941
  $.each(UTILITIES, function(name, utility) {
@@ -1778,6 +2099,11 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1778
2099
  blank = inherited
1779
2100
  ? t('utility.inherit', { value: labelOf(family, inherited.value), breakpoint: inherited.tier.key })
1780
2101
  : t('utility.default');
2102
+
2103
+ // What the empty choice means is sometimes not the family's
2104
+ // own business: a column's width can come from its row
2105
+ var custom = family.blank ? family.blank(node, curView) : null;
2106
+ if (custom && own === null) { blank = custom; }
1781
2107
  }
1782
2108
 
1783
2109
  // A value the markup carries is shown even when the family
@@ -1876,17 +2202,6 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1876
2202
 
1877
2203
  createMoveTool(drawer);
1878
2204
  addSettingsTool(drawer, container, settings.container_classes);
1879
- if (definition.addPane) {
1880
- createTool(drawer, t(definition.addPaneKey), 'ge-add-pane', 'bi bi-plus-circle', function() {
1881
- var pane = definition.addPane(container, {});
1882
-
1883
- addNode(definition.paneKind, pane, function() {}, {
1884
- parent: container,
1885
- source: 'tool',
1886
- container: container,
1887
- });
1888
- });
1889
- }
1890
2205
 
1891
2206
  settings.container_tools.forEach(function(hostTool) {
1892
2207
  createTool(drawer, hostTool.title || '', hostTool.className || '',
@@ -1900,6 +2215,19 @@ $.fn.gridEditor = function( optionsOrMethod ) {
1900
2215
  container.slideUp(removed);
1901
2216
  });
1902
2217
  });
2218
+
2219
+ // Delete then add, the order rows and columns have
2220
+ if (definition.addPane) {
2221
+ createTool(drawer, t(definition.addPaneKey), 'ge-add-pane', 'bi bi-plus-circle', function() {
2222
+ var pane = definition.addPane(container, {});
2223
+
2224
+ addNode(definition.paneKind, pane, function() {}, {
2225
+ parent: container,
2226
+ source: 'tool',
2227
+ container: container,
2228
+ });
2229
+ });
2230
+ }
1903
2231
  }
1904
2232
 
1905
2233
  /**
@@ -2129,9 +2457,10 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2129
2457
  settings.valid_col_sizes.forEach(function(size) {
2130
2458
  $('<a class="ge-size" />')
2131
2459
  .attr('data-ge-size', size)
2132
- .attr('title', t('tool.column_size', { size: size }))
2133
- .toggleClass('ge-size-tight', size > room)
2134
- .text(size)
2460
+ .attr('title', sizeTitle(size))
2461
+ .toggleClass('ge-size-tight', isUnits(size) && size > room)
2462
+ .toggleClass('ge-size-flex', !isUnits(size))
2463
+ .text(isUnits(size) ? size : sizeClass(BREAKPOINTS[0], size))
2135
2464
  .on('click', function(e) {
2136
2465
  e.preventDefault();
2137
2466
  e.stopPropagation();
@@ -2152,6 +2481,13 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2152
2481
  }
2153
2482
 
2154
2483
  /** True when there was one to close, which is also a click's answer. */
2484
+ function sizeTitle(size) {
2485
+ if (size === 'equal') { return t('tool.column_equal'); }
2486
+ if (size === 'auto') { return t('tool.column_auto'); }
2487
+
2488
+ return t('tool.column_size', { size: size });
2489
+ }
2490
+
2155
2491
  function closeSizePicker() {
2156
2492
  if (!sizePicker) { return false; }
2157
2493
 
@@ -2183,7 +2519,8 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2183
2519
  createTool(drawer, t('tool.add_column'), 'ge-add-column', 'bi bi-plus-circle', function() {
2184
2520
  if (closeSizePicker()) { return; } // The picker was open: that was the answer
2185
2521
 
2186
- addColumnTo(row, settings.add_column.size);
2522
+ // In a row with row-cols the new column takes its share
2523
+ addColumnTo(row, rowColsSource(row, leadingTier()) ? null : settings.add_column.size);
2187
2524
  });
2188
2525
 
2189
2526
  attachSizePicker(drawer.find('> .ge-add-column'), row);
@@ -2203,12 +2540,15 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2203
2540
  createTool(drawer, t('tool.column_narrower'), 'ge-decrease-col-width', 'bi bi-dash-lg', function(e) {
2204
2541
  resizeColumn(col, e.shiftKey
2205
2542
  ? smallest(settings.valid_col_sizes)
2206
- : stepThrough(settings.valid_col_sizes, currentSize(col), -1),
2543
+ : stepThrough(settings.valid_col_sizes.filter(isUnits), currentUnits(col), -1),
2207
2544
  'tool');
2208
2545
  });
2209
2546
 
2210
2547
  createTool(drawer, t('tool.column_wider'), 'ge-increase-col-width', 'bi bi-plus-lg', function(e) {
2211
- resizeColumn(col, e.shiftKey ? widestFor(col) : stepThrough(settings.valid_col_sizes, currentSize(col), 1), 'tool');
2548
+ resizeColumn(col, e.shiftKey
2549
+ ? widestFor(col)
2550
+ : stepThrough(settings.valid_col_sizes.filter(isUnits), currentUnits(col), 1),
2551
+ 'tool');
2212
2552
  });
2213
2553
 
2214
2554
  createTool(drawer, t('tool.indent_decrease'), 'ge-decrease-col-offset', 'bi bi-text-indent-right', function(e) {
@@ -2259,17 +2599,43 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2259
2599
  * view it is the widest tier, because the canvas is not constrained
2260
2600
  * there and the widest tier is what the user is looking at: clicking
2261
2601
  * "narrower" on a column authored as col-lg-6 should take it to 5,
2262
- * not to 11 because no xs class was ever written.
2602
+ * not to 11 because no base class was ever written. What the all view
2603
+ * writes is the base class all the same.
2263
2604
  */
2264
2605
  function leadingTier() {
2265
2606
  return curView === ALL_VIEW ? BREAKPOINTS[BREAKPOINTS.length - 1] : breakpoint(curView);
2266
2607
  }
2267
2608
 
2609
+ /** The size that applies in the view: units, `equal` or `auto`. */
2268
2610
  function currentSize(col) {
2269
- var size = getEffectiveSize(col, leadingTier());
2611
+ var tier = leadingTier();
2612
+
2613
+ // Sized by its row: it has no size of its own to report
2614
+ if (rowColsWinner(col, tier)) { return null; }
2615
+
2616
+ var size = getEffectiveSize(col, tier);
2270
2617
  return size === null ? MAX_COL_SIZE : size;
2271
2618
  }
2272
2619
 
2620
+ /**
2621
+ * The size in units, which is what the tools step through. An equal
2622
+ * or auto column has no number, so it is measured: the width it has
2623
+ * on the canvas, in twelfths of its row.
2624
+ */
2625
+ function currentUnits(col) {
2626
+ var size = currentSize(col);
2627
+ if (isUnits(size)) { return size; }
2628
+
2629
+ var units = Math.round(col.outerWidth() / rowContentWidth(col.parent()) * MAX_COL_SIZE);
2630
+ return Math.min(Math.max(units, 1), MAX_COL_SIZE);
2631
+ }
2632
+
2633
+ function rowContentWidth(row) {
2634
+ var style = window.getComputedStyle(row[0]);
2635
+
2636
+ return row[0].clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
2637
+ }
2638
+
2273
2639
  function currentOffset(col) {
2274
2640
  return getEffectiveOffset(col, leadingTier()) || 0;
2275
2641
  }
@@ -2289,11 +2655,11 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2289
2655
  }
2290
2656
 
2291
2657
  function smallest(values) {
2292
- return values.reduce(function(a, b) { return Math.min(a, b); }, MAX_COL_SIZE);
2658
+ return values.filter(isUnits).reduce(function(a, b) { return Math.min(a, b); }, MAX_COL_SIZE);
2293
2659
  }
2294
2660
 
2295
2661
  function largest(values) {
2296
- return values.reduce(function(a, b) { return Math.max(a, b); }, 0);
2662
+ return values.filter(isUnits).reduce(function(a, b) { return Math.max(a, b); }, 0);
2297
2663
  }
2298
2664
 
2299
2665
  /**
@@ -2311,7 +2677,7 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2311
2677
  * itself left inside the row.
2312
2678
  */
2313
2679
  function deepestFor(col) {
2314
- var room = spare(col.parent(), leadingTier(), col) - currentSize(col);
2680
+ var room = spare(col.parent(), leadingTier(), col) - currentUnits(col);
2315
2681
 
2316
2682
  return Math.min(largest(settings.valid_col_offsets), Math.max(room, 0));
2317
2683
  }
@@ -2354,6 +2720,7 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2354
2720
  function isEditorClass(name) {
2355
2721
  if (name === 'row' || name === 'column') { return true; }
2356
2722
  if (/^(ge-|ui-)/.test(name)) { return true; }
2723
+ if (/^col(-(sm|md|lg|xl|xxl))?(-auto)?$/.test(name)) { return true; }
2357
2724
 
2358
2725
  return BREAKPOINTS.some(function(tier) {
2359
2726
  return new RegExp('^(' + tier.colPrefix + '|' + tier.offsetPrefix + ')\\d+$').test(name);
@@ -2382,11 +2749,16 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2382
2749
  });
2383
2750
 
2384
2751
  // Beside the gear, because every node that has one is a node a
2385
- // utility may apply to, whichever plugin built its drawer
2752
+ // utility may apply to, whichever plugin built its drawer. A
2753
+ // feature plugin's tools go there too, for the same reason: the
2754
+ // gear is the one tool every drawer has.
2386
2755
  var kind = kindOf(node);
2387
2756
  $.each(UTILITIES, function(name, utility) {
2388
2757
  if (utility.drawerTools) { utility.drawerTools(drawer, node, kind); }
2389
2758
  });
2759
+ $.each(FEATURES, function(name, feature) {
2760
+ if (feature.drawerTools) { feature.drawerTools(drawer, node, kind); }
2761
+ });
2390
2762
 
2391
2763
  return details.appendTo(drawer);
2392
2764
  }
@@ -2452,15 +2824,137 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2452
2824
  * column that had none.
2453
2825
  */
2454
2826
  function addAllColClasses() {
2455
- canvas.find('.column, div[class*="col-"]').each(function() {
2827
+ canvas.find('.column, div[class*="col-"], div.col').each(function() {
2456
2828
  var col = $(this).addClass('column');
2457
2829
 
2458
- if (sizedTiers(col).length) { return; }
2830
+ // A column in a row with row-cols is sized by its row
2831
+ if (sizedTiers(col).length || hasRowCols(col.parent())) { return; }
2459
2832
 
2460
2833
  setSize(col, BREAKPOINTS[0], MAX_COL_SIZE);
2461
2834
  });
2462
2835
  }
2463
2836
 
2837
+ /* --------------------------------------------------------------
2838
+ * Columns per row: row-cols-*.
2839
+ *
2840
+ * A row's row-cols class gives every child an equal share of a
2841
+ * line, and a column's own size class takes that share's place.
2842
+ * Which of the two wins at a breakpoint is settled the way
2843
+ * Bootstrap's css settles it: the class from the wider breakpoint,
2844
+ * and at one breakpoint col loses to row-cols, which loses to
2845
+ * col-auto and col-N - the order Bootstrap writes their rules in.
2846
+ * -------------------------------------------------------------- */
2847
+
2848
+ function hasRowCols(row) {
2849
+ return row.hasClass('row') && BREAKPOINTS.some(function(tier) {
2850
+ return ROW_COLS_VALUES.some(function(value) { return row.hasClass(rowColsClass(tier, value)); });
2851
+ });
2852
+ }
2853
+
2854
+ /** The row-cols class that applies to a row at a tier, as { tier, value }, or null. */
2855
+ function rowColsSource(row, tier) {
2856
+ if (!row.hasClass('row')) { return null; }
2857
+
2858
+ for (var i = BREAKPOINTS.indexOf(tier); i >= 0; i--) {
2859
+ for (var v = 0; v < ROW_COLS_VALUES.length; v++) {
2860
+ if (row.hasClass(rowColsClass(BREAKPOINTS[i], ROW_COLS_VALUES[v]))) {
2861
+ return { tier: i, value: ROW_COLS_VALUES[v] };
2862
+ }
2863
+ }
2864
+ }
2865
+
2866
+ return null;
2867
+ }
2868
+
2869
+ /** The column's own size that applies at a tier, as { tier, size }, or null. */
2870
+ function columnSource(col, tier) {
2871
+ for (var i = BREAKPOINTS.indexOf(tier); i >= 0; i--) {
2872
+ var size = getSize(col, BREAKPOINTS[i]);
2873
+ if (size !== null) { return { tier: i, size: size }; }
2874
+ }
2875
+
2876
+ return null;
2877
+ }
2878
+
2879
+ /**
2880
+ * The row-cols class that sizes this column at a tier, or null when
2881
+ * the column's own size does.
2882
+ */
2883
+ function rowColsWinner(col, tier) {
2884
+ var row = rowColsSource(col.parent(), tier);
2885
+ if (!row) { return null; }
2886
+
2887
+ var own = columnSource(col, tier);
2888
+ if (!own || row.tier > own.tier) { return row; }
2889
+
2890
+ return row.tier === own.tier && own.size === 'equal' ? row : null;
2891
+ }
2892
+
2893
+ /** A column's share of a line when its row sizes it, in units: auto has no number. */
2894
+ function rowColsUnits(winner) {
2895
+ return winner.value === 'auto' ? 0 : MAX_COL_SIZE / parseInt(winner.value, 10);
2896
+ }
2897
+
2898
+ /**
2899
+ * What a breakpoint view shows of the row-cols a column is under. The
2900
+ * view narrows the canvas, not the window, and Bootstrap's
2901
+ * .row-cols-md-3 > * answers to the window - and to nothing the
2902
+ * preview can put on the row, since it styles the row's children.
2903
+ */
2904
+ function rowColsPreview(col, tier) {
2905
+ var winner = rowColsWinner(col, tier);
2906
+
2907
+ if (winner) {
2908
+ return {
2909
+ flex: '0 0 auto',
2910
+ width: winner.value === 'auto' ? 'auto' : (100 / parseInt(winner.value, 10)) + '%',
2911
+ 'max-width': '100%',
2912
+ };
2913
+ }
2914
+
2915
+ // A wider breakpoint's row-cols is live in a wide window; a
2916
+ // column with no size of its own here is a full width one
2917
+ if (hasRowCols(col.parent()) && !columnSource(col, tier)) {
2918
+ return { flex: '0 0 auto', width: '100%', 'max-width': '100%' };
2919
+ }
2920
+
2921
+ return {};
2922
+ }
2923
+
2924
+ /**
2925
+ * The badge on a row whose columns are sized by row-cols in the view,
2926
+ * and in the all view by its row-cols with no breakpoint.
2927
+ */
2928
+ function markRowCols(scope) {
2929
+ scope.find('.row').addBack('.row').each(function() {
2930
+ var row = $(this);
2931
+ var source = settings.row_cols === false ? null : (curView === ALL_VIEW
2932
+ ? (rowColsSource(row, BREAKPOINTS[0]) || null)
2933
+ : rowColsSource(row, breakpoint(curView)));
2934
+
2935
+ if (!source) {
2936
+ row.removeAttr('data-ge-row-cols');
2937
+ } else {
2938
+ row.attr('data-ge-row-cols', rowColsLabel(source.value));
2939
+ }
2940
+ });
2941
+ }
2942
+
2943
+ function rowColsLabel(value) {
2944
+ return value === 'auto' ? t('badge.row_cols_auto') : t('badge.row_cols', { count: value });
2945
+ }
2946
+
2947
+ /** The core's family for the field in a row's panel. */
2948
+ function rowColsFamily() {
2949
+ return {
2950
+ name: 'row-cols',
2951
+ prefix: 'row-cols',
2952
+ values: ROW_COLS_VALUES,
2953
+ appliesTo: ['row'],
2954
+ labelKey: 'utility.row_cols',
2955
+ };
2956
+ }
2957
+
2464
2958
  /* --------------------------------------------------------------
2465
2959
  * The sizing core.
2466
2960
  *
@@ -2470,9 +2964,18 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2470
2964
  * names, the 12 unit budget and what an absent class means.
2471
2965
  * -------------------------------------------------------------- */
2472
2966
 
2473
- /** The units a column is given at one tier, or null if that tier says nothing. */
2967
+ /**
2968
+ * A column's size at one tier: a number of units, `equal`, `auto`, or
2969
+ * null when that tier says nothing.
2970
+ */
2474
2971
  function getSize(col, tier) {
2475
- return readUnits(col, tier.colPrefix);
2972
+ var units = readUnits(col, tier.colPrefix);
2973
+ if (units !== null) { return units; }
2974
+
2975
+ if (col.hasClass(sizeClass(tier, 'auto'))) { return 'auto'; }
2976
+ if (col.hasClass(sizeClass(tier, 'equal'))) { return 'equal'; }
2977
+
2978
+ return null;
2476
2979
  }
2477
2980
 
2478
2981
  /** The units a column is indented by at one tier, or null. */
@@ -2522,8 +3025,12 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2522
3025
  col.attr('class', classes.join(' '));
2523
3026
  }
2524
3027
 
2525
- function setSize(col, tier, units) {
2526
- writeUnits(col, tier.colPrefix, units);
3028
+ /** One size per tier: writing a size takes whatever size the tier had off. */
3029
+ function setSize(col, tier, size) {
3030
+ writeUnits(col, tier.colPrefix, null);
3031
+ FLEX_SIZES.forEach(function(flex) { col.removeClass(sizeClass(tier, flex)); });
3032
+
3033
+ if (size !== null && size !== undefined) { col.addClass(sizeClass(tier, size)); }
2527
3034
  }
2528
3035
 
2529
3036
  /** An offset of 0 is written as no class at all, which is what it means. */
@@ -2549,7 +3056,11 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2549
3056
  var sibling = $(this);
2550
3057
  if (ignore && sibling[0] === ignore[0]) { return; }
2551
3058
 
2552
- used += (getEffectiveSize(sibling, tier) || 0) + (getEffectiveOffset(sibling, tier) || 0);
3059
+ // An equal or auto column takes what is left, so it uses none;
3060
+ // one its row sizes uses its share of the line
3061
+ var winner = rowColsWinner(sibling, tier);
3062
+ var size = winner ? rowColsUnits(winner) : getEffectiveSize(sibling, tier);
3063
+ used += (isUnits(size) ? size : 0) + (getEffectiveOffset(sibling, tier) || 0);
2553
3064
  });
2554
3065
 
2555
3066
  return MAX_COL_SIZE - used;
@@ -2570,7 +3081,7 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2570
3081
 
2571
3082
  offset = Math.min(Math.max(offset, 0), MAX_COL_OFFSET);
2572
3083
 
2573
- if (size === null) { return { size: null, offset: offset, refused: false }; }
3084
+ if (size === null || !isUnits(size)) { return { size: size, offset: offset, refused: false }; }
2574
3085
 
2575
3086
  size = Math.min(Math.max(size, 1), MAX_COL_SIZE);
2576
3087
 
@@ -2614,6 +3125,35 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2614
3125
  return 'ge-' + name + '-' + instanceId;
2615
3126
  }
2616
3127
 
3128
+ /**
3129
+ * What the canvas, the columns and the plugins' regions move: rows,
3130
+ * content areas and containers, and whatever blocks a feature adds -
3131
+ * the sections plugin's sections.
3132
+ */
3133
+ function blockSelector() {
3134
+ return ['.row', '.ge-content', '[data-ge-container]'].concat(pluginHooks('blocks')).join(', ');
3135
+ }
3136
+
3137
+ /** A selector every feature plugin may contribute to, under one name. */
3138
+ function pluginHooks(name) {
3139
+ return $.map(FEATURES, function(feature) { return feature[name] || null; });
3140
+ }
3141
+
3142
+ /**
3143
+ * Whether a region will have a block dropped in it. Every block goes
3144
+ * everywhere unless a feature says otherwise: a section only on the
3145
+ * canvas, and only rows in a section.
3146
+ */
3147
+ function acceptsBlock(region, node) {
3148
+ var accepted = true;
3149
+
3150
+ $.each(FEATURES, function(name, feature) {
3151
+ if (accepted && feature.accepts && feature.accepts(region, node) === false) { accepted = false; }
3152
+ });
3153
+
3154
+ return accepted;
3155
+ }
3156
+
2617
3157
  /** What must never start a drag, whatever the handle is. */
2618
3158
  function dragCancelSelector() {
2619
3159
  return settings.drag_handle === 'drawer'
@@ -2651,6 +3191,16 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2651
3191
  ? { name: groupName(options.group) }
2652
3192
  : { name: groupName(options.draggable + '-' + (++soloGroup)), pull: false, put: false };
2653
3193
 
3194
+ // A list may turn some of its group's items away. A put
3195
+ // function replaces SortableJS's own test that the item comes
3196
+ // from the same group, so it makes that test too: without it
3197
+ // a column would drop into the canvas, or into another editor
3198
+ if (options.group && options.accepts) {
3199
+ group.put = function(to, from, dragged) {
3200
+ return from.options.group.name === group.name && options.accepts($(to.el), $(dragged));
3201
+ };
3202
+ }
3203
+
2654
3204
  sortables.push(window.Sortable.create(list, $.extend({
2655
3205
  group: group,
2656
3206
  draggable: options.draggable,
@@ -2715,9 +3265,24 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2715
3265
  });
2716
3266
 
2717
3267
  sortable(canvas.add(canvas.find('.column')), {
2718
- draggable: '.row, .ge-content, [data-ge-container]',
3268
+ draggable: blockSelector(),
2719
3269
  group: 'block',
2720
- });
3270
+ accepts: acceptsBlock,
3271
+ });
3272
+
3273
+ // A plugin's region can be a block itself - a section is both -
3274
+ // and SortableJS tests a list's own element against the draggable
3275
+ // selector when it counts the list's children, so every child of
3276
+ // a section would count as a block, its drawer first. '>' is
3277
+ // SortableJS for "direct children only", which the list is not.
3278
+ var regions = pluginHooks('regions');
3279
+ if (regions.length) {
3280
+ sortable(canvas.find(regions.join(', ')), {
3281
+ draggable: '>' + blockSelector(),
3282
+ group: 'block',
3283
+ accepts: acceptsBlock,
3284
+ });
3285
+ }
2721
3286
 
2722
3287
  // A plugin makes its own: only it knows which of its parts move
2723
3288
  plugins('onSortable', sortable);
@@ -2889,20 +3454,21 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2889
3454
  * divider between two columns looks like it should do.
2890
3455
  */
2891
3456
  function snapUnits(col, pixels) {
2892
- var row = col.parent();
2893
- var style = window.getComputedStyle(row[0]);
2894
- var content = row[0].clientWidth -
2895
- parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
2896
-
2897
- var units = Math.round(pixels / content * MAX_COL_SIZE);
3457
+ var units = Math.round(pixels / rowContentWidth(col.parent()) * MAX_COL_SIZE);
2898
3458
  var next = balanceSibling(col);
3459
+ var nextSize = next ? currentSize(next) : null;
3460
+
3461
+ // Measured when the drag started: the column is the width of the
3462
+ // pointer now, which is not what it was
3463
+ var own = col.data('ge-resize-units');
2899
3464
 
2900
3465
  // With a sibling to balance against, the drag may take that
2901
3466
  // column's units but not its last one. Without one, the row is
2902
3467
  // allowed to wrap - that is what balance false means - so the only
2903
3468
  // limit is the column's own budget against its indent.
2904
- var room = next
2905
- ? currentSize(col) + currentSize(next) - smallest(settings.valid_col_sizes)
3469
+ // An equal or auto sibling gives way by itself, so it sets no limit
3470
+ var room = next && isUnits(nextSize)
3471
+ ? own + nextSize - smallest(settings.valid_col_sizes)
2906
3472
  : MAX_COL_SIZE - currentOffset(col);
2907
3473
 
2908
3474
  return Math.min(
@@ -2948,7 +3514,8 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2948
3514
  if (!allowed) { return false; }
2949
3515
 
2950
3516
  col.data('ge-resize-from', from);
2951
- resizeReadout(col, sizeLabel(from));
3517
+ col.data('ge-resize-units', currentUnits(col));
3518
+ resizeReadout(col, sizeLabel(col.data('ge-resize-units')));
2952
3519
 
2953
3520
  return true;
2954
3521
  }
@@ -2959,30 +3526,33 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2959
3526
 
2960
3527
  function resizeStop(col, width) {
2961
3528
  var from = col.data('ge-resize-from');
3529
+ var started = col.data('ge-resize-units');
2962
3530
  var units = snapUnits(col, width);
2963
3531
 
2964
- col.removeData('ge-resize-from');
3532
+ col.removeData('ge-resize-from').removeData('ge-resize-units');
2965
3533
  resizeReadout(col, '');
2966
3534
  stripPixelWidths(col);
2967
3535
 
2968
3536
  if (from === undefined) { return; }
2969
3537
 
2970
3538
  // A drag of a couple of pixels lands on the size it started from,
2971
- // and is not a resize
2972
- if (units === from) { return; }
3539
+ // and is not a resize - not even of an equal or auto column,
3540
+ // which it would otherwise turn into a number
3541
+ if (units === started) { return; }
2973
3542
 
2974
3543
  var plan = planSize(col, units);
2975
3544
  if (!plan) { return; }
2976
3545
 
2977
3546
  operate(function() {
2978
3547
  writeSize(col, plan);
2979
- balanceAfterResize(col, plan.size - from);
3548
+ balanceAfterResize(col, units - started);
3549
+ refreshUtilities(col);
2980
3550
 
2981
- emit('after-resize', payloadFor('column', col, {
3551
+ emit('after-resize', payloadFor('column', col, withCleared({
2982
3552
  source: 'dragdrop',
2983
3553
  from: from,
2984
3554
  to: plan.size,
2985
- }));
3555
+ }, plan.cleared)));
2986
3556
  });
2987
3557
  }
2988
3558
 
@@ -2990,12 +3560,22 @@ $.fn.gridEditor = function( optionsOrMethod ) {
2990
3560
  * Move the delta into the following column, so a full row stays full.
2991
3561
  * It is part of the same gesture, so it is not announced separately.
2992
3562
  */
3563
+ /**
3564
+ * The column after takes what the resized one gave or took, unless it
3565
+ * is equal or auto: those take what is left by themselves.
3566
+ */
2993
3567
  function balanceAfterResize(col, delta) {
2994
3568
  var next = balanceSibling(col);
2995
3569
  if (!next || !delta) { return; }
2996
3570
 
2997
- var plan = planSize(next, currentSize(next) - delta);
2998
- if (plan) { writeSize(next, plan); }
3571
+ var size = currentSize(next);
3572
+ if (!isUnits(size)) { return; }
3573
+
3574
+ var plan = planSize(next, size - delta);
3575
+ if (plan) {
3576
+ writeSize(next, plan);
3577
+ refreshUtilities(next);
3578
+ }
2999
3579
  }
3000
3580
 
3001
3581
  /**
@@ -3045,7 +3625,7 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3045
3625
  var add = function() {
3046
3626
  return addNode(kind, node, function() {
3047
3627
  node[placement](options[placement]);
3048
- }, { parent: parent, source: 'api' });
3628
+ }, { parent: parent, source: options.source || 'api' });
3049
3629
  };
3050
3630
 
3051
3631
  if (operationDepth > 0) {
@@ -3062,13 +3642,15 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3062
3642
  function apiCreateRow(layout, options) {
3063
3643
  var row = createRow();
3064
3644
 
3065
- if (layout !== undefined && !Array.isArray(layout)) {
3066
- warn('createRow: the layout is an array of column sizes, as in [8, 4]. ' +
3067
- 'Making an empty row instead.');
3645
+ if (layout !== undefined && !Array.isArray(layout) && !(layout && layout.row_cols)) {
3646
+ warn('createRow: the layout is an array of column sizes, as in [8, 4], or ' +
3647
+ '{ row_cols, columns }. Making an empty row instead.');
3068
3648
  layout = [];
3069
3649
  }
3070
3650
 
3071
- (layout || []).forEach(function(size) {
3651
+ if (layout && !Array.isArray(layout)) { row = rowFromLayoutValue(layout); }
3652
+
3653
+ (Array.isArray(layout) ? layout : []).forEach(function(size) {
3072
3654
  createColumn(size).appendTo(row);
3073
3655
  });
3074
3656
 
@@ -3081,7 +3663,12 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3081
3663
  function apiCreateColumn(size, options) {
3082
3664
  options = options || {};
3083
3665
 
3084
- if (typeof size != 'number') {
3666
+ // Going into a row with row-cols, no size is a size: the row's share
3667
+ var into = $(options.appendTo || options.prependTo || []).first();
3668
+
3669
+ if (size === undefined && into.length && rowColsSource(into, leadingTier())) {
3670
+ size = null;
3671
+ } else if (!isUnits(size) && FLEX_SIZES.indexOf(size) === -1) {
3085
3672
  warn('createColumn: no column size given, using ' + MAX_COL_SIZE);
3086
3673
  size = MAX_COL_SIZE;
3087
3674
  }
@@ -3152,8 +3739,8 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3152
3739
  }
3153
3740
 
3154
3741
  /**
3155
- * A column sized for the current view: one tier, or every tier in the
3156
- * all view. `offset` indents it, within the same 12 unit budget.
3742
+ * A column sized for the current view: its tier, or the base class in
3743
+ * the all view. `offset` indents it, within the same 12 unit budget.
3157
3744
  */
3158
3745
  function createColumn(size, offset) {
3159
3746
  var rte = getRTE(settings.content_types[0]);
@@ -3161,12 +3748,12 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3161
3748
  .append(createDefaultContentWrapper().html(rte ? rte.initialContent : ''))
3162
3749
  ;
3163
3750
 
3164
- tiersFor(curView).forEach(function(tier) {
3165
- var wanted = clamp({ size: size, offset: offset || 0, leading: 'offset' });
3751
+ // The tier being edited, or the base class in the all view
3752
+ var tier = tiersFor(curView)[0];
3753
+ var wanted = clamp({ size: size, offset: offset || 0, leading: 'offset' });
3166
3754
 
3167
- setSize(column, tier, wanted.size === null ? size : wanted.size);
3168
- setOffset(column, tier, wanted.offset);
3169
- });
3755
+ setSize(column, tier, wanted.size === null ? size : wanted.size);
3756
+ setOffset(column, tier, wanted.offset);
3170
3757
 
3171
3758
  return column;
3172
3759
  }
@@ -3232,11 +3819,19 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3232
3819
  return $();
3233
3820
  }
3234
3821
 
3822
+ /**
3823
+ * A content area for the first content type. With no content types
3824
+ * it is a content area and nothing more: there is no type to name.
3825
+ */
3235
3826
  function createDefaultContentWrapper() {
3236
- return $('<div/>')
3237
- .addClass('ge-content ge-content-type-' + settings.content_types[0])
3238
- .attr('data-ge-content-type', settings.content_types[0])
3239
- ;
3827
+ var type = settings.content_types[0];
3828
+ var contentArea = $('<div class="ge-content"/>');
3829
+
3830
+ if (type) {
3831
+ contentArea.addClass('ge-content-type-' + type).attr('data-ge-content-type', type);
3832
+ }
3833
+
3834
+ return contentArea;
3240
3835
  }
3241
3836
 
3242
3837
  /**
@@ -3304,6 +3899,7 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3304
3899
  */
3305
3900
  var handle = {
3306
3901
  getHtml: getHtml,
3902
+ getPlainHtml: getPlainHtml,
3307
3903
  // init and reset are deferred when a handler calls them, so an
3308
3904
  // operation in flight finishes before the canvas is rebuilt
3309
3905
  init: function() { defer(init); },
@@ -3324,6 +3920,15 @@ $.fn.gridEditor = function( optionsOrMethod ) {
3324
3920
 
3325
3921
  return featureMethods.createElement(content, options);
3326
3922
  },
3923
+ createSection: function(options) {
3924
+ if (!featureMethods.createSection) {
3925
+ warnOnceHere('plugin:sections', 'createSection needs the sections plugin: ' +
3926
+ 'include dist/plugins/grideditor.sections.js after the editor');
3927
+ return null;
3928
+ }
3929
+
3930
+ return featureMethods.createSection(options);
3931
+ },
3327
3932
  createContainer: apiCreateContainer,
3328
3933
  addTab: function(container, options) { return addPaneTo(container, 'tabs', options); },
3329
3934
  addAccordionItem: function(container, options) {
@@ -3416,6 +4021,8 @@ $.fn.gridEditor.locales = {
3416
4021
  'tool.add_row': 'Add row',
3417
4022
  'tool.add_column': 'Add column\n(hold to choose the width)',
3418
4023
  'tool.column_size': '{size} of 12',
4024
+ 'tool.column_equal': 'Equal: shares what the row has left',
4025
+ 'tool.column_auto': 'Auto: as wide as its content',
3419
4026
  'tool.delete_row': 'Remove row',
3420
4027
  'tool.delete_column': 'Remove col',
3421
4028
  'tool.delete_container': 'Remove container',
@@ -3433,6 +4040,7 @@ $.fn.gridEditor.locales = {
3433
4040
  'tool.classes_title': 'Css classes, separated by spaces',
3434
4041
  'tool.toggle_class': 'Toggle "{label}" styling',
3435
4042
  'row.add': 'Add row {layout}',
4043
+ 'row.add_row_cols': 'Add a row of {columns} columns, {counts} per row',
3436
4044
  'confirm.title': 'Confirm',
3437
4045
  'confirm.ok': 'Delete',
3438
4046
  'confirm.cancel': 'Cancel',
@@ -3447,6 +4055,13 @@ $.fn.gridEditor.locales = {
3447
4055
  'view.xl': 'Large desktop',
3448
4056
  'view.xxl': 'Widescreen',
3449
4057
  'utility.section': 'Responsive: {view}',
4058
+ 'utility.col_width': 'Width',
4059
+ 'utility.col_equal': 'Equal',
4060
+ 'utility.col_auto': 'Auto',
4061
+ 'utility.col_from_row': 'From the row: {count}',
4062
+ 'utility.row_cols': 'Columns per row',
4063
+ 'badge.row_cols': '{count} per row',
4064
+ 'badge.row_cols_auto': 'As wide as their content',
3450
4065
  'utility.default': 'Default',
3451
4066
  'utility.inherit': 'Inherit: {value} (from {breakpoint})',
3452
4067
  'utility.varies': 'Changes at {breakpoints}; choosing here replaces that',