mapshaper 0.7.64 → 0.7.66

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.
@@ -11373,10 +11373,22 @@
11373
11373
  }
11374
11374
 
11375
11375
  if (lyr && lyr.gui.geographic) {
11376
+ // Above the actions, because it is what they would be applied to: the
11377
+ // labels that share something with the one pointed at, read and chosen
11378
+ // before "copy as GeoJSON" or "delete label" means anything definite.
11379
+ // Each item carries the number it would select, so that what the click
11380
+ // is about to do is visible before it happens.
11381
+ if (e.selectActions?.length) {
11382
+ addMenuLabel('select');
11383
+ e.selectActions.forEach(function(action) {
11384
+ addMenuItem(action.label + getCountHtml(action.count), action.run);
11385
+ });
11386
+ }
11387
+
11376
11388
  if (e.deleteVertex || e.deletePoint || copyable || e.deleteFeature ||
11377
11389
  e.flipLabel) {
11378
11390
 
11379
- addMenuLabel('selection');
11391
+ addMenuLabel('actions');
11380
11392
  if (e.deleteVertex) {
11381
11393
  addMenuItem('delete vertex', e.deleteVertex);
11382
11394
  }
@@ -11524,6 +11536,12 @@
11524
11536
  return 'band values';
11525
11537
  }
11526
11538
 
11539
+ // How many features a "select" item would select, in the grey of a menu
11540
+ // heading: it qualifies the item rather than being part of what it says.
11541
+ function getCountHtml(count) {
11542
+ return ' <span class="contextmenu-count">' + count + '</span>';
11543
+ }
11544
+
11527
11545
  // A color tile, used in place of the bullet that other menu items get.
11528
11546
  function getSwatchHtml(color) {
11529
11547
  return '<span class="contextmenu-swatch" style="background-color: ' +
@@ -16193,6 +16211,13 @@
16193
16211
  setDisplay(shown);
16194
16212
  };
16195
16213
 
16214
+ // What the field shows while it is blank, or '' for nothing. The panel uses
16215
+ // it to say that the selected features disagree about the size, which is one
16216
+ // of the two reasons the field can be empty.
16217
+ this.setPlaceholder = function(str) {
16218
+ input.attr('placeholder', str || '');
16219
+ };
16220
+
16196
16221
  this.setDisabled = function(off) {
16197
16222
  disabled = !!off;
16198
16223
  input.node().disabled = disabled;
@@ -16551,6 +16576,24 @@
16551
16576
  gui.state.new_label_style = null;
16552
16577
  }
16553
16578
 
16579
+ // What a two-state control shows for a group of labels: 'on', 'off', or
16580
+ // 'mixed' when they disagree.
16581
+ //
16582
+ // The panel's other controls say "they disagree" by showing nothing -- an empty
16583
+ // select, an unlit button, a blank field. A switch has no empty state to fall
16584
+ // back on, so it needs a third one named here.
16585
+ //
16586
+ // flags: one truthy/falsy value per label, in any order
16587
+ function getToggleState(flags) {
16588
+ var on = 0;
16589
+ var i;
16590
+ for (i = 0; i < flags.length; i++) {
16591
+ if (flags[i]) on++;
16592
+ }
16593
+ if (on === 0) return 'off';
16594
+ return on == flags.length ? 'on' : 'mixed';
16595
+ }
16596
+
16554
16597
  // What a drag on a label's glyphs means: 'fixed' moves the label, anchor and
16555
16598
  // text together, and 'draggable' leaves the anchor where it is and offsets the
16556
16599
  // text from it.
@@ -16663,10 +16706,15 @@
16663
16706
  // aligned: whether it carries a label-align (see below)
16664
16707
  // delta: {dx, dy} -- how far the pointer has moved, in the same space.
16665
16708
  //
16666
- // Returns {dx, dy, text-anchor, x}, where x is where the text will be drawn
16667
- // once the other three are written -- the same as dx except on an aligned
16668
- // label. The three have to be written together: applying dx without its
16669
- // text-anchor moves the text by half its own width or by all of it.
16709
+ // Returns two descriptions of the same placement:
16710
+ //
16711
+ // dx, dy, text-anchor: what the drag writes to the record. The three have to
16712
+ // be written together: applying dx without its text-anchor moves the text
16713
+ // by half its own width or by all of it.
16714
+ // x, anchor: where the text sits and how it is justified while it is drawn,
16715
+ // which is what the preview writes onto the rendered label. The same as
16716
+ // dx and text-anchor except on an aligned label, whose stored offset is
16717
+ // measured against a justification it is not drawn with.
16670
16718
  function getOffsetDragValues(start, delta) {
16671
16719
  var width = start.width > 0 ? start.width : 0;
16672
16720
  var drawnAnchor = normalizeAnchor(start.anchor);
@@ -16685,12 +16733,18 @@
16685
16733
  // if the alignment were later removed.
16686
16734
  var anchor = start.aligned ? 'start' :
16687
16735
  width > 0 ? getAnchorForCentre(left + width / 2, width) : drawnAnchor;
16736
+ // An aligned label goes on being drawn with the anchor its alignment gives
16737
+ // it, whatever the drag writes: the alignment is unchanged, so the renderer
16738
+ // will justify it the same way afterwards. Drawing it as 'start' instead
16739
+ // would slide the text right by half its width or by all of it for the
16740
+ // length of the drag, and drop it back on release.
16741
+ var drawn = start.aligned ? drawnAnchor : anchor;
16688
16742
  return {
16689
16743
  dx: round$1(left + anchorOffsets[anchor] * width),
16690
16744
  dy: round$1(start.dy + delta.dy),
16691
16745
  'text-anchor': anchor,
16692
- x: round$1(start.aligned ? start.dx + delta.dx :
16693
- left + anchorOffsets[anchor] * width)
16746
+ x: round$1(left + anchorOffsets[drawn] * width),
16747
+ anchor: drawn
16694
16748
  };
16695
16749
  }
16696
16750
 
@@ -16771,6 +16825,11 @@
16771
16825
  // no line-height until one is chosen. "auto" is the honest description of a
16772
16826
  // blank field: something else decides.
16773
16827
  var lineHeightPlaceholder = 'auto';
16828
+ // What a control shows for a selection whose labels disagree about it. One
16829
+ // word, used in every field and menu that can be in that state: the panel
16830
+ // otherwise says it by showing nothing, which is also what an unset property
16831
+ // looks like.
16832
+ var MIXED_TEXT = 'mixed';
16774
16833
  var labelStyleMode = 'label_style';
16775
16834
  var labelStylePanelMode = 'label_style_tool';
16776
16835
  var labelMode = 'label';
@@ -17070,8 +17129,12 @@
17070
17129
  applyInlineCss(cssInput.node().value.trim());
17071
17130
  });
17072
17131
 
17132
+ // The caption for the grid and the drag-mode toggle below it, and what
17133
+ // gives Fixed|Draggable something to be an adjective of. "Position" read as
17134
+ // the label's own position -- which a drag moves in either mode -- and made
17135
+ // Fixed sound like a lock on a label that is still perfectly draggable.
17073
17136
  var positionRow = El('label').addClass('label-style-row').appendTo(textSection);
17074
- El('span').appendTo(positionRow).text('Position');
17137
+ El('span').appendTo(positionRow).text('Offset from anchor');
17075
17138
 
17076
17139
  // Whether the label has a symbol is one question and which symbol it has is
17077
17140
  // another, so the first is a switch on the section's heading rather than a
@@ -17200,26 +17263,38 @@
17200
17263
  return makePanelSection(panel, title, opts);
17201
17264
  }
17202
17265
 
17203
- // A two-state switch: a track with a knob that sits left when off and right
17204
- // when on, which is the direction users expect and the only thing that says
17205
- // which state is which without a label for each.
17266
+ // A switch: a track with a knob that sits left when off and right when on,
17267
+ // which is the direction users expect and the only thing that says which
17268
+ // state is which without a label for each. A third state says that the
17269
+ // labels it is asking about disagree -- the knob sits over the join of a
17270
+ // half-and-half track, which is the panel's only control that has to show
17271
+ // "some of them" rather than a value.
17272
+ //
17273
+ // role=checkbox rather than switch, which is what it looks like: 'mixed' is a
17274
+ // legal aria-checked value for a checkbox and not for a switch, and a switch
17275
+ // reporting a mixed selection as unchecked would be telling a screen reader
17276
+ // the one thing the third state exists to avoid saying.
17206
17277
  function makeToggle(parent, opts) {
17207
- var track = El('div').addClass('label-toggle').attr('role', 'switch').appendTo(parent);
17208
- var on = false;
17278
+ var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
17279
+ var state = 'off';
17209
17280
  var disabled = false;
17210
17281
  El('div').addClass('label-toggle-knob').appendTo(track);
17211
17282
  if (opts.title) track.attr('title', opts.title);
17283
+ // A click on a mixed switch turns everything on. It is the convention, and
17284
+ // it is the reading that reaches a state the switch can describe: the next
17285
+ // click then turns everything off, so both are one click away.
17212
17286
  track.on('click', function() {
17213
17287
  if (disabled) return;
17214
- opts.onChange(!on);
17288
+ opts.onChange(state != 'on');
17215
17289
  });
17216
17290
  return {
17217
- setState: function(isOn) {
17218
- on = !!isOn;
17219
- track.classed('on', on).attr('aria-checked', on ? 'true' : 'false');
17220
- },
17221
- getState: function() {
17222
- return on;
17291
+ // val: 'on', 'off' or 'mixed'; anything else reads as off
17292
+ setState: function(val) {
17293
+ state = val == 'on' || val == 'mixed' ? val : 'off';
17294
+ track.classed('on', state == 'on')
17295
+ .classed('mixed', state == 'mixed')
17296
+ .attr('aria-checked', state == 'mixed' ? 'mixed' :
17297
+ state == 'on' ? 'true' : 'false');
17223
17298
  },
17224
17299
  setDisabled: function(off) {
17225
17300
  disabled = !!off;
@@ -17242,6 +17317,9 @@
17242
17317
  function makeMeasureInput(parent, field, placeholder) {
17243
17318
  var input = El('input').attr('type', 'text').addClass('label-measure-input')
17244
17319
  .attr('placeholder', placeholder)
17320
+ // The field's own placeholder, kept because the shown one is replaced
17321
+ // while the selection disagrees -- see setMixedPlaceholder().
17322
+ .attr('data-placeholder', placeholder)
17245
17323
  .appendTo(parent)
17246
17324
  .on('change', function() {
17247
17325
  applyStyleValues([[field, input.node().value.trim()]]);
@@ -17413,51 +17491,72 @@
17413
17491
  // With nothing selected the menu shows the font the next label will be
17414
17492
  // made in; with labels selected it shows the font they are drawn in, which
17415
17493
  // for a label carrying none is whatever sans-serif resolves to here.
17416
- var fontVal = getCommonValue(ids, fontField,
17494
+ var font = getShownValue(ids, fontField,
17417
17495
  {useDefault: true, defaultValue: getFontNameForTarget(ids)});
17418
- var fontSizeVal = getCommonValue(ids, fontSizeField, {useDefault: true, defaultValue: defaultFontSize});
17496
+ var fontSize = getShownValue(ids, fontSizeField, {useDefault: true, defaultValue: defaultFontSize});
17419
17497
  var fontStyleVal = getCommonValue(ids, fontStyleField, {useDefault: true, defaultValue: defaultFontStyle});
17420
17498
  var fontWeightVal = getCommonValue(ids, fontWeightField, {useDefault: true, defaultValue: defaultFontWeight});
17421
- var fillVal = getCommonValue(ids, fillField, {useDefault: true, defaultValue: defaultLabelColor});
17422
- var opacityVal = getCommonValue(ids, opacityField, {useDefault: true, defaultValue: 1});
17423
- var letterSpacingVal = getCommonValue(ids, letterSpacingField);
17424
- var lineHeightVal = getCommonValue(ids, lineHeightField);
17499
+ var fill = getShownValue(ids, fillField, {useDefault: true, defaultValue: defaultLabelColor});
17500
+ var opacity = getShownValue(ids, opacityField, {useDefault: true, defaultValue: 1});
17501
+ var letterSpacing = getShownValue(ids, letterSpacingField);
17502
+ var lineHeight = getShownValue(ids, lineHeightField);
17425
17503
  var alignVal = getCommonAlignment(ids);
17426
- var cssVal = getCommonValue(ids, cssField);
17504
+ var css = getShownValue(ids, cssField);
17427
17505
  var posVal = getCommonValue(ids, 'label-pos');
17428
- var iconVal = getCommonValue(ids, iconField);
17429
- var iconSizeVal = getCommonValue(ids, iconSizeField, {useDefault: true, defaultValue: defaultIconSize});
17430
- var iconColorVal = getCommonValue(ids, iconColorField, {useDefault: true, defaultValue: defaultIconColor});
17431
- var iconOpacityVal = getCommonValue(ids, iconOpacityField, {useDefault: true, defaultValue: 1});
17506
+ // From the labels that have a symbol, so that a selection where only some
17507
+ // do shows the symbols it has rather than blanking every field because the
17508
+ // rest of the selection has nothing to compare.
17509
+ var iconIds = getIconValueIds();
17510
+ var iconVal = getCommonValue(iconIds, iconField);
17511
+ var iconSize = getShownValue(iconIds, iconSizeField, {useDefault: true, defaultValue: defaultIconSize});
17512
+ var iconColor = getShownValue(iconIds, iconColorField, {useDefault: true, defaultValue: defaultIconColor});
17513
+ var iconOpacity = getShownValue(iconIds, iconOpacityField, {useDefault: true, defaultValue: 1});
17432
17514
  updateEditingStatus(manualIds.length, !!getLabelTextSession(gui));
17433
17515
  updateSavedStyleControls();
17434
17516
  fontSelect.node().disabled = !showValues;
17435
- updateFontControl(fontVal);
17436
- updateFontStyleControls(fontVal, fontStyleVal, fontWeightVal);
17437
- updateFontSizeControls(showValues ? fontSizeVal : '');
17438
- updateColorControls(showValues ? fillVal : '');
17439
- updateOpacityControl(opacityInput, showValues ? opacityVal : '');
17440
- updateMeasureControl(letterSpacingInput, showValues ? letterSpacingVal : '');
17441
- updateMeasureControl(lineHeightInput, showValues ? lineHeightVal : '');
17517
+ updateFontControl(font);
17518
+ updateFontStyleControls(font.value, fontStyleVal, fontWeightVal);
17519
+ updateFontSizeControls(fontSize);
17520
+ updateColorControls(fill);
17521
+ updateOpacityControl(opacityInput, opacity);
17522
+ updateMeasureControl(letterSpacingInput, letterSpacing);
17523
+ updateMeasureControl(lineHeightInput, lineHeight);
17442
17524
  updateAlignButtons(showValues ? alignVal : '');
17443
- updateCssControl(showValues ? cssVal : '');
17525
+ updateCssControl(css);
17444
17526
  updatePositionButtons(showValues ? posVal : '', ids);
17445
17527
  // The symbol's controls keep showing their values while the switch is off,
17446
17528
  // greyed: what they show is what the symbol comes back as.
17447
17529
  var iconOff = updateIconControls(showValues ? iconVal : '');
17448
- updateIconSizeControls(showValues ? iconSizeVal : '', iconOff);
17449
- updateIconColorControls(showValues ? iconColorVal : '', iconOff);
17450
- updateOpacityControl(iconOpacityInput, showValues ? iconOpacityVal : '', iconOff);
17530
+ updateIconSizeControls(iconSize, iconOff);
17531
+ updateIconColorControls(iconColor, iconOff);
17532
+ updateOpacityControl(iconOpacityInput, iconOpacity, iconOff);
17451
17533
  }
17452
17534
 
17453
- function updateOpacityControl(input, val, disabled) {
17535
+ // Every field that can show a value can also show nothing, which is why each
17536
+ // of these takes a {value, mixed} rather than a value: a field blank because
17537
+ // the selected labels disagree says so, in the placeholder, where one blank
17538
+ // because the property is unset shows what the renderer will do instead.
17539
+ function updateOpacityControl(input, shown, disabled) {
17454
17540
  input.node().disabled = disabled || !controlsEnabled();
17455
- input.node().value = val === '' ? '' : formatOpacityPct(val);
17541
+ input.node().value = shown.value === '' ? '' : formatOpacityPct(shown.value);
17542
+ setMixedPlaceholder(input, shown.mixed);
17456
17543
  }
17457
17544
 
17458
- function updateMeasureControl(input, val) {
17545
+ function updateMeasureControl(input, shown) {
17546
+ var val = shown.value;
17459
17547
  input.node().disabled = !controlsEnabled();
17460
17548
  input.node().value = val || val === 0 ? String(val) : '';
17549
+ setMixedPlaceholder(input, shown.mixed);
17550
+ }
17551
+
17552
+ // A field showing nothing because the labels disagree says "mixed" where it
17553
+ // would otherwise show its own placeholder -- the spacing fields show the
17554
+ // value the renderer uses when the property is absent, which is not what is
17555
+ // true of a mixed selection.
17556
+ function setMixedPlaceholder(input, mixed) {
17557
+ var el = input.node();
17558
+ var own = el.getAttribute('data-placeholder') || '';
17559
+ el.setAttribute('placeholder', mixed ? MIXED_TEXT : own);
17461
17560
  }
17462
17561
 
17463
17562
  // Alignment stays live whatever is selected, including nothing. It was
@@ -17481,7 +17580,6 @@
17481
17580
  // to take. The commands ignore a position given for one, and a disabled
17482
17581
  // button says so where a console warning would not.
17483
17582
  var disabled = !controlsEnabled() || everyLabelIsOnAPath(ids);
17484
- var locked = !disabled && gridIsLockedToCentre(ids);
17485
17583
  // Faintly, and only when no cell is lit: a label carrying offsets is not
17486
17584
  // at any of the nine, but one of them is roughly where it is and clicking
17487
17585
  // it is the way back.
@@ -17489,37 +17587,11 @@
17489
17587
  labelPositions.forEach(function(name) {
17490
17588
  posBtns[name].classed('selected', !disabled && name == pos);
17491
17589
  posBtns[name].classed('nearest', name == nearest);
17492
- setPanelButtonDisabled(posBtns[name], disabled || locked && name != 'c');
17590
+ setPanelButtonDisabled(posBtns[name], disabled);
17493
17591
  });
17494
17592
  updateDragModeButtons();
17495
17593
  }
17496
17594
 
17497
- // The nine positions place text around something, so with nothing drawn at
17498
- // the anchor the grid is locked to the centre cell: there is no answer to
17499
- // "north-east of what?", and the centre is what -add-label gives a new label
17500
- // anyway. The cell stays clickable, because text over its own symbol is a
17501
- // real thing to ask for.
17502
- //
17503
- // The test is whether the label draws a symbol, not whether it has an icon:
17504
- // a styled dots layer given label text by the point panel's "Create labels"
17505
- // keeps its r and fill, and keying this on icon= alone would lock the
17506
- // commonest labels in the app to the middle of their own dots.
17507
- //
17508
- // A default and a guard rather than an invariant. A label that arrives
17509
- // positioned with nothing at its anchor -- from the CLI, from an expression,
17510
- // from the legacy positioning mode -- is shown as it is, with the grid live:
17511
- // refusing to display what is in the record is worse than letting an odd
17512
- // state be edited. Dragging is not gated on a symbol either, since a labels
17513
- // layer whose dots live in a different layer still needs its text placed.
17514
- function gridIsLockedToCentre(ids) {
17515
- var table = getActiveTable();
17516
- if (!everyTargetLacksASymbol(ids)) return false;
17517
- if (ids.length === 0) return !labelIsPlaced(getNewLabelStyle(gui));
17518
- return ids.every(function(id) {
17519
- return !labelIsPlaced(table && table.getRecordAt(id));
17520
- });
17521
- }
17522
-
17523
17595
  // Whether a label has been put somewhere other than on top of its anchor: a
17524
17596
  // position of its own other than the centre one, or offsets from a drag.
17525
17597
  function labelIsPlaced(rec) {
@@ -17529,16 +17601,6 @@
17529
17601
  internal.hasStyleValue(rec, 'dy');
17530
17602
  }
17531
17603
 
17532
- function everyTargetLacksASymbol(ids) {
17533
- var table = getActiveTable();
17534
- if (ids.length === 0) {
17535
- return !internal.featureHasSvgSymbol(getNewLabelStyle(gui));
17536
- }
17537
- return ids.every(function(id) {
17538
- return !internal.featureHasSvgSymbol(table && table.getRecordAt(id));
17539
- });
17540
- }
17541
-
17542
17604
  // Which of the nine positions a dragged label is nearest, or ''.
17543
17605
  //
17544
17606
  // Only for a single label: the cell is a hint about where one label sits,
@@ -17616,8 +17678,9 @@
17616
17678
  return labelModeIsOn() ? 'new labels' : 'all';
17617
17679
  }
17618
17680
 
17619
- function updateFontSizeControls(fontSizeVal) {
17620
- fontSizeInput.setValue(fontSizeVal || '');
17681
+ function updateFontSizeControls(shown) {
17682
+ fontSizeInput.setValue(shown.value || '');
17683
+ fontSizeInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
17621
17684
  fontSizeInput.setDisabled(!controlsEnabled());
17622
17685
  }
17623
17686
 
@@ -17630,15 +17693,43 @@
17630
17693
  // here, and a project moves between machines. It is added to the menu as
17631
17694
  // itself rather than replaced by an installed font, since the name is the
17632
17695
  // user's data and choosing something else for them would restyle the label.
17633
- function updateFontControl(fontVal) {
17696
+ function updateFontControl(shown) {
17697
+ var fontVal = shown.value;
17698
+ // A font menu is a list of fonts, so "they are in different fonts" cannot
17699
+ // be one of the fonts: it is an entry that appears only while it is true,
17700
+ // and picking it does nothing (applyFont() ignores a blank value). The
17701
+ // list stays live, so choosing a font is how the selection is brought into
17702
+ // line -- as it is for the face menu below.
17703
+ setMixedOption(fontSelect, shown.mixed);
17634
17704
  fontSelect.node().value = fontVal || '';
17635
17705
  if (fontVal && fontSelect.node().selectedIndex < 0) {
17636
17706
  El('option').attr('value', fontVal).text(fontVal).appendTo(fontSelect);
17637
17707
  fontSelect.node().value = fontVal;
17638
17708
  }
17639
- // Nothing selected, rather than the first font in the list, for a
17640
- // selection whose labels are in different fonts.
17641
- if (!fontVal) fontSelect.node().selectedIndex = -1;
17709
+ // Nothing selected, rather than the first font in the list, for a target
17710
+ // there is nothing to say about -- a disabled menu with no labels behind it.
17711
+ if (!fontVal && !shown.mixed) fontSelect.node().selectedIndex = -1;
17712
+ }
17713
+
17714
+ // Adds or removes a menu's "mixed" entry, which carries no value: a select
17715
+ // showing nothing looks like a control that failed to load, and this is the
17716
+ // one state the panel cannot express by lighting no button.
17717
+ //
17718
+ // First in the list, and removed as soon as the selection agrees, so that it
17719
+ // is never an option among the real ones.
17720
+ function setMixedOption(select, mixed) {
17721
+ var el = select.node();
17722
+ var first = el.options[0];
17723
+ var has = !!first && first.value === '' && first.dataset.mixed == 'true';
17724
+ if (mixed && !has) {
17725
+ first = document.createElement('option');
17726
+ first.value = '';
17727
+ first.textContent = MIXED_TEXT;
17728
+ first.dataset.mixed = 'true';
17729
+ el.insertBefore(first, el.firstChild);
17730
+ } else if (!mixed && has) {
17731
+ el.removeChild(first);
17732
+ }
17642
17733
  }
17643
17734
 
17644
17735
  // The faces the chosen font is installed with, and no entry for "whatever
@@ -17658,12 +17749,13 @@
17658
17749
  El('option').attr('value', variant.value).appendTo(fontStyleSelect).text(variant.label);
17659
17750
  });
17660
17751
  }
17752
+ // The faces are rebuilt above, so the "mixed" entry goes on afterwards.
17753
+ setMixedOption(fontStyleSelect, mixed);
17661
17754
  fontStyleSelect.node().disabled = disabled;
17662
17755
  fontStyleSelect.node().value = shown ? shown.value : '';
17663
- // Nothing selected rather than the first face, for a selection that does
17664
- // not agree on one: the list is still live, so picking a face is how the
17665
- // selection is brought into line.
17666
- if (!shown) fontStyleSelect.node().selectedIndex = -1;
17756
+ // Nothing selected rather than the first face, for a menu with no font to
17757
+ // list the faces of.
17758
+ if (!shown && !mixed) fontStyleSelect.node().selectedIndex = -1;
17667
17759
  }
17668
17760
 
17669
17761
  // The face the panel shows for a label in @fontName. A font-style and
@@ -17674,12 +17766,17 @@
17674
17766
  return getNearestFontStyleVariant(fontName, fontStyleVal, fontWeightVal);
17675
17767
  }
17676
17768
 
17677
- function updateColorControls(colorVal) {
17769
+ function updateColorControls(shown) {
17770
+ var colorVal = shown.value;
17678
17771
  var disabled = !controlsEnabled();
17679
17772
  colorInput.node().disabled = disabled;
17680
17773
  colorInput.node().value = colorVal || '';
17774
+ setMixedPlaceholder(colorInput, shown.mixed);
17681
17775
  colorFieldBox.classed('disabled', disabled);
17682
17776
  setPanelButtonDisabled(colorChit, disabled);
17777
+ // An empty swatch is how the field says "no colour", so a mixed one is
17778
+ // marked instead of being left blank to mean two things.
17779
+ colorChit.classed('mixed', shown.mixed);
17683
17780
  colorChit.css('background-color', isHexColor(colorVal) ? colorVal : 'transparent');
17684
17781
  if (colorPicker.visible()) {
17685
17782
  return; // avoid HSB -> RGB -> HSB rounding jumps after picker commits
@@ -17691,50 +17788,59 @@
17691
17788
  }
17692
17789
  }
17693
17790
 
17694
- function updateCssControl(cssVal) {
17791
+ function updateCssControl(shown) {
17695
17792
  cssInput.node().disabled = !controlsEnabled();
17696
- cssInput.node().value = cssVal || '';
17793
+ cssInput.node().value = shown.value || '';
17794
+ setMixedPlaceholder(cssInput, shown.mixed);
17697
17795
  }
17698
17796
 
17699
17797
  // The toggle reads the data rather than holding a state of its own: a symbol
17700
17798
  // is on when the target has one, which is what makes it follow an undo.
17701
17799
  //
17702
- // A selection where only some labels have a symbol counts as on, so that the
17703
- // section stays usable -- turning the switch off then removes every symbol in
17704
- // it, and a shape applies to all of them. Treating it as off would show the
17705
- // one state from which nothing in the section can be reached.
17800
+ // A selection where only some labels have a symbol shows the switch mixed,
17801
+ // and the section stays usable: there are symbols in the selection to style.
17802
+ // What the controls below it then act on narrows to the labels that have one
17803
+ // -- see getIconTargetIds() -- so styling a symbol never creates one. The
17804
+ // switch is still the only way to ask for that, and clicking a shape is the
17805
+ // one thing in the section that applies to every selected label, because
17806
+ // choosing a shape for a group is a plain statement about all of it.
17706
17807
  function updateIconControls(iconVal) {
17707
17808
  var enabled = controlsEnabled();
17708
- var on = enabled && !everyTargetLacksAnIcon();
17809
+ var state = enabled ? getIconState() : 'off';
17810
+ var off = state == 'off';
17709
17811
  if (iconVal) lastIconShape = iconVal;
17710
- iconToggle.setState(on);
17812
+ iconToggle.setState(state);
17711
17813
  iconToggle.setDisabled(!enabled);
17712
17814
  // The group is faded as a whole rather than button by button, so that the
17713
17815
  // border the buttons share fades with them -- a live border around dead
17714
17816
  // buttons is the one part of a disabled control that still looks usable.
17715
- iconGroupEl.classed('disabled', !on);
17817
+ iconGroupEl.classed('disabled', off);
17716
17818
  iconTypes.forEach(function(icon) {
17717
- iconBtns[icon.name].classed('selected', on && icon.name == iconVal);
17718
- setPanelButtonDisabled(iconBtns[icon.name], !on);
17819
+ iconBtns[icon.name].classed('selected', !off && icon.name == iconVal);
17820
+ setPanelButtonDisabled(iconBtns[icon.name], off);
17719
17821
  });
17720
- return !on;
17822
+ return off;
17721
17823
  }
17722
17824
 
17723
17825
  function setIconOn(on) {
17724
17826
  applyIcon(on ? lastIconShape : '');
17725
17827
  }
17726
17828
 
17727
- function updateIconSizeControls(iconSizeVal, iconOff) {
17728
- iconSizeInput.setValue(iconSizeVal || '');
17829
+ function updateIconSizeControls(shown, iconOff) {
17830
+ iconSizeInput.setValue(shown.value || '');
17831
+ iconSizeInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
17729
17832
  iconSizeInput.setDisabled(iconOff || !controlsEnabled());
17730
17833
  }
17731
17834
 
17732
- function updateIconColorControls(colorVal, iconOff) {
17835
+ function updateIconColorControls(shown, iconOff) {
17836
+ var colorVal = shown.value;
17733
17837
  var disabled = iconOff || !controlsEnabled();
17734
17838
  iconColorInput.node().disabled = disabled;
17735
17839
  iconColorInput.node().value = colorVal || '';
17840
+ setMixedPlaceholder(iconColorInput, shown.mixed);
17736
17841
  iconColorFieldBox.classed('disabled', disabled);
17737
17842
  setPanelButtonDisabled(iconColorChit, disabled);
17843
+ iconColorChit.classed('mixed', shown.mixed);
17738
17844
  iconColorChit.css('background-color', isHexColor(colorVal) ? colorVal : 'transparent');
17739
17845
  if (iconColorPicker.visible()) {
17740
17846
  return; // avoid HSB -> RGB -> HSB rounding jumps after picker commits
@@ -17751,29 +17857,57 @@
17751
17857
  }
17752
17858
 
17753
17859
  function getCommonValue(ids, field, opts) {
17860
+ return getCommonValueInfo(ids, field, opts).value;
17861
+ }
17862
+
17863
+ // What the target labels say about one property:
17864
+ //
17865
+ // value: the value they agree on, or '' if they do not
17866
+ // mixed: whether they disagree
17867
+ //
17868
+ // Both, because a control showing nothing has to be able to say which of the
17869
+ // two kinds of nothing it means. Blank is also what an unset property looks
17870
+ // like -- a label carries no letter-spacing until one is chosen -- and a
17871
+ // field that reads the same either way leaves the user to guess whether the
17872
+ // selection is uniform.
17873
+ function getCommonValueInfo(ids, field, opts) {
17754
17874
  var table = getActiveTable();
17755
17875
  var records = table && table.getRecords();
17756
17876
  var value, val, hasValue;
17757
- if (ids.length === 0) return getNewLabelValue(field, opts);
17758
- if (!records) return '';
17877
+ if (ids.length === 0) {
17878
+ return {value: getNewLabelValue(field, opts), mixed: false};
17879
+ }
17880
+ if (!records) return {value: '', mixed: false};
17759
17881
  for (var i=0; i<ids.length; i++) {
17760
17882
  val = records[ids[i]] && records[ids[i]][field];
17761
17883
  if (!val) {
17762
- if (opts && opts.useDefault) {
17763
- val = opts.defaultValue;
17764
- } else {
17765
- return '';
17766
- }
17884
+ // A property nobody set reads as what it renders as, where the caller
17885
+ // says what that is: a label with font-size=12 and one with no
17886
+ // font-size are drawn the same and agree. Without a default the two
17887
+ // disagree, which is right for a property that means nothing when it
17888
+ // is absent -- one label carrying inline css and one not are not in
17889
+ // the same state.
17890
+ val = opts && opts.useDefault ? opts.defaultValue : '';
17767
17891
  } else {
17768
17892
  hasValue = true;
17769
17893
  }
17770
17894
  if (i === 0) {
17771
17895
  value = val;
17772
17896
  } else if (val != value) {
17773
- return '';
17897
+ return {value: '', mixed: true};
17774
17898
  }
17775
17899
  }
17776
- return hasValue || opts && opts.useDefault ? value : '';
17900
+ return {
17901
+ value: hasValue || opts && opts.useDefault ? value : '',
17902
+ mixed: false
17903
+ };
17904
+ }
17905
+
17906
+ // The same, as a control shows it: nothing at all, and nothing to say about
17907
+ // it, while there is no target to read.
17908
+ function getShownValue(ids, field, opts) {
17909
+ if (!controlsEnabled()) return {value: '', mixed: false};
17910
+ return getCommonValueInfo(ids, field, opts);
17777
17911
  }
17778
17912
 
17779
17913
  // The alignment the labels are *drawn* with, rather than the one they carry.
@@ -18002,7 +18136,11 @@
18002
18136
  var ids = getTargetIds();
18003
18137
  var styles = [[iconField, iconName || '']];
18004
18138
  if (iconName) {
18005
- styles.push([iconSizeField, getNumericSize(ids, iconSizeField, defaultIconSize)]);
18139
+ // The size the symbols in the selection already share, where they share
18140
+ // one: a shape applied to a mixed selection gives the labels that had no
18141
+ // symbol the size of the ones that did, rather than resetting them all to
18142
+ // the default.
18143
+ styles.push([iconSizeField, getNumericSize(getIconValueIds(), iconSizeField, defaultIconSize)]);
18006
18144
  // The symbol's own opacity goes on with it, because the label's opacity
18007
18145
  // is applied to both elements: without this, text set to 50% would give
18008
18146
  // a half-faded symbol while the Icon section showed it at 100%.
@@ -18014,25 +18152,21 @@
18014
18152
  applyStyleValues(styles);
18015
18153
  }
18016
18154
 
18017
- // Switching a symbol on or off changes which positions are legal, so the
18018
- // position moves with it -- in the same command, which makes the pair one
18019
- // undo step.
18155
+ // Switching a symbol on moves a label sitting at the centre out from under
18156
+ // it, in the same command, so that the pair is one undo step and the text
18157
+ // is never briefly drawn over the symbol it just asked for.
18020
18158
  //
18021
- // On: the centre cell stops being the default and a label sitting there
18022
- // moves out from under its new symbol. Off: the grid locks back to the
18023
- // centre, so the position goes and the offsets go with it -- label-pos=c
18024
- // clears dx, dy and text-anchor by itself. That makes switching a symbol off
18025
- // destructive, since a hand-placed label loses its placement; being one
18026
- // undoable command is what makes that acceptable.
18159
+ // Switching one off moves nothing. It used to put the label back to the
18160
+ // centre, which threw away a placement the user had made by hand for the
18161
+ // sake of an invariant -- that text with nothing at its anchor sits on the
18162
+ // anchor -- that was never worth what it cost.
18027
18163
  //
18028
18164
  // Not for path labels, whose text runs along a curve: the commands ignore a
18029
18165
  // position given for one and warn about it, and a warning from switching a
18030
18166
  // symbol on would be about something the user did not ask for.
18031
18167
  function addIconPositionChange(styles, ids, iconOn) {
18032
- if (anyLabelIsOnAPath(ids)) return;
18033
- if (!iconOn) {
18034
- styles.push(['label-pos', 'c']);
18035
- } else if (everyTargetIsCentred(ids)) {
18168
+ if (anyLabelIsOnAPath(ids) || !iconOn) return;
18169
+ if (everyTargetIsCentred(ids)) {
18036
18170
  styles.push(['label-pos', labelPositionBesideIcon]);
18037
18171
  }
18038
18172
  }
@@ -18046,45 +18180,74 @@
18046
18180
  }
18047
18181
 
18048
18182
  function applyIconSize(value) {
18049
- applyStyleValues([[iconSizeField, value]]);
18183
+ applyStyleValues([[iconSizeField, value]], getIconTargetIds());
18050
18184
  }
18051
18185
 
18052
18186
  function applyIconColor(color) {
18053
- applyStyleValues([[iconColorField, color]]);
18187
+ applyStyleValues([[iconColorField, color]], getIconTargetIds());
18054
18188
  }
18055
18189
 
18056
18190
  function applyIconOpacity(value) {
18057
- applyStyleValues([[iconOpacityField, value]]);
18191
+ applyStyleValues([[iconOpacityField, value]], getIconTargetIds());
18058
18192
  }
18059
18193
 
18060
18194
  function nudgeIconSize(delta) {
18061
- var ids = getTargetIds();
18062
- var size = getNumericSize(ids, iconSizeField, defaultIconSize);
18195
+ var size = getNumericSize(getIconTargetIds(), iconSizeField, defaultIconSize);
18063
18196
  if (!controlsEnabled()) return;
18064
18197
  size = Math.max(1, size + delta);
18065
18198
  applyIconSize(size);
18066
18199
  }
18067
18200
 
18201
+ // Whether the target labels have a symbol: 'on', 'off', or 'mixed' when only
18202
+ // some of them do.
18203
+ //
18068
18204
  // An icon-size or icon-color on a label with no symbol draws nothing, which
18069
18205
  // is why the shape, size and colour controls are inert until the toggle puts
18070
18206
  // a symbol there to style.
18071
- function everyTargetLacksAnIcon() {
18207
+ function getIconState() {
18072
18208
  var ids = getTargetIds();
18073
18209
  var table = getActiveTable();
18074
- if (ids.length === 0) return !getNewLabelValue(iconField);
18075
- return ids.every(function(id) {
18210
+ if (ids.length === 0) return getNewLabelValue(iconField) ? 'on' : 'off';
18211
+ return getToggleState(ids.map(function(id) {
18212
+ var rec = table && table.getRecordAt(id);
18213
+ return !!(rec && rec[iconField]);
18214
+ }));
18215
+ }
18216
+
18217
+ // The labels an icon-size, icon-color or icon-opacity goes to: those in the
18218
+ // target that have a symbol, which is all of them unless the switch is mixed.
18219
+ // Writing one of these to a label with no icon would add a property that
18220
+ // draws nothing and a column to the user's table.
18221
+ //
18222
+ // Empty for a target with no symbol anywhere, which is the state those
18223
+ // controls are disabled in, and empty for "new labels", where the style is
18224
+ // held by the tool rather than by any feature.
18225
+ function getIconTargetIds() {
18226
+ var ids = getTargetIds();
18227
+ var table = getActiveTable();
18228
+ return ids.filter(function(id) {
18076
18229
  var rec = table && table.getRecordAt(id);
18077
- return !(rec && rec[iconField]);
18230
+ return !!(rec && rec[iconField]);
18078
18231
  });
18079
18232
  }
18080
18233
 
18234
+ // The labels the section's controls *show* the values of, which is not quite
18235
+ // the set they write to: with no symbol anywhere the fields go on showing
18236
+ // what the target carries, greyed, because that is what the symbol comes back
18237
+ // as when the switch is turned on. Narrowing there would show the values held
18238
+ // for the next label instead of the ones stored on the selection.
18239
+ function getIconValueIds() {
18240
+ var ids = getIconTargetIds();
18241
+ return ids.length > 0 ? ids : getTargetIds();
18242
+ }
18243
+
18081
18244
  function getIconOpacityToWrite() {
18082
18245
  var val = parseOpacityValue(iconOpacityInput.node().value);
18083
18246
  if (val !== null) return val;
18084
18247
  // The field reads blank while the section is off, so a symbol switched off
18085
18248
  // and on again takes back the fade still stored on the label rather than
18086
18249
  // being reset to full.
18087
- var stored = getCommonValue(getTargetIds(), iconOpacityField, {useDefault: true, defaultValue: 1});
18250
+ var stored = getCommonValue(getIconValueIds(), iconOpacityField, {useDefault: true, defaultValue: 1});
18088
18251
  val = stored === '' ? 1 : Number(stored);
18089
18252
  return isFinite(val) ? val : 1;
18090
18253
  }
@@ -18137,7 +18300,10 @@
18137
18300
  }
18138
18301
  }
18139
18302
 
18140
- function applyStyleValues(styles) {
18303
+ // idsArg: (optional) the labels to write to, when they are not the whole
18304
+ // target -- an icon property goes only to the labels that have a symbol.
18305
+ function applyStyleValues(styles, idsArg) {
18306
+ var ids = idsArg || getTargetIds();
18141
18307
  if (styles.length === 0) return;
18142
18308
  releaseFocus();
18143
18309
  // What the panel is set to is always what the next label gets, whether or
@@ -18150,8 +18316,8 @@
18150
18316
  // style, so it has to be redrawn to show the change -- it is the preview.
18151
18317
  refreshPendingLabel();
18152
18318
  }
18153
- if (getTargetIds().length > 0) {
18154
- applyStyleCommand(styles);
18319
+ if (ids.length > 0) {
18320
+ applyStyleCommand(styles, ids);
18155
18321
  return;
18156
18322
  }
18157
18323
  if (!labelModeIsOn()) return;
@@ -18163,9 +18329,8 @@
18163
18329
  if (session && session.id == -1 && session.refresh) session.refresh();
18164
18330
  }
18165
18331
 
18166
- function applyStyleCommand(styles) {
18332
+ function applyStyleCommand(styles, ids) {
18167
18333
  var lyr = getActiveLayer();
18168
- var ids = getTargetIds();
18169
18334
  var parts = ['-style'];
18170
18335
  if (!gui.console || !lyr) return;
18171
18336
  styles.forEach(function(style) {
@@ -25332,6 +25497,9 @@
25332
25497
  // See docs/development/label-tool-design.md.
25333
25498
 
25334
25499
  var SVG_NS$2 = 'http://www.w3.org/2000/svg';
25500
+ // How far outside its text a label's outline is drawn. Exported because the
25501
+ // tool grabs a label by the same box: what looks like the object is what takes
25502
+ // a drag on it.
25335
25503
  var BOX_PADDING = 3;
25336
25504
  var ANCHOR_RADIUS = 3.5;
25337
25505
  var KNOT_RADIUS = 3;
@@ -25343,6 +25511,13 @@
25343
25511
  // layer instead of rebuilding it, and the cue moves with the map by wearing the
25344
25512
  // label's own transform -- but a select-all on a big layer would still pay it,
25345
25513
  // for an outline per label too small to tell apart anyway.
25514
+ //
25515
+ // Over the cap the selected labels wear a halo instead (.label-cue-marked),
25516
+ // which is a class on the text node and costs no measurement. It says less
25517
+ // than an outline -- no anchors, no knots, no box -- but a selection of
25518
+ // hundreds is a group being restyled rather than objects being handled one by
25519
+ // one, and the one thing it has to say is which labels are in it. This used to
25520
+ // draw nothing at all, so selecting a whole layer left the map unchanged.
25346
25521
  var MAX_OUTLINES = 200;
25347
25522
 
25348
25523
  // getEditingId: returns the feature id of an open text editing session, or -1.
@@ -25350,6 +25525,7 @@
25350
25525
  function LabelSelection(gui, ext, hit, getEditingId) {
25351
25526
  var self = {};
25352
25527
  var groups = []; // one <g> per drawn cue, in the layer's markup
25528
+ var marked = []; // text nodes wearing the halo, when there are too many to outline
25353
25529
  var drawn = null; // what those cues represent, so hover does not redraw them
25354
25530
  var on = false;
25355
25531
  var tetherId = -1; // the label whose text is being dragged off its anchor
@@ -25379,9 +25555,11 @@
25379
25555
  // markup and takes the old cues with it.
25380
25556
  self.refresh = function(force) {
25381
25557
  var target = hit.getHitTarget();
25382
- var ids = on ? getDrawableIds() : [];
25383
- var hoverId = on ? getHoverId(ids) : -1;
25384
- var key = ids.join(',') + '/' + hoverId + '/' + tetherId;
25558
+ var selected = on ? hit.getSelectionIds() : [];
25559
+ var tooMany = selected.length > MAX_OUTLINES;
25560
+ var ids = tooMany ? [] : selected;
25561
+ var hoverId = on ? getHoverId(selected) : -1;
25562
+ var key = selected.join(',') + '/' + hoverId + '/' + tetherId;
25385
25563
  // Hover fires on every pointer move, and most of them change nothing here.
25386
25564
  if (!force && drawn === key) return;
25387
25565
  clearAll();
@@ -25396,6 +25574,9 @@
25396
25574
  // would offer handles that cannot be grabbed.
25397
25575
  draw(target, id, 'label-cue-selected', true);
25398
25576
  });
25577
+ // Too many to outline: a halo on the glyphs instead, which is the whole cue
25578
+ // for those labels.
25579
+ if (tooMany) markAll(target, selected);
25399
25580
  };
25400
25581
 
25401
25582
  // The label under the pointer, when showing it would say something: not one
@@ -25407,10 +25588,23 @@
25407
25588
  return id;
25408
25589
  }
25409
25590
 
25410
- // The selected labels, or none if there are too many to outline usefully.
25411
- function getDrawableIds() {
25412
- var ids = hit.getSelectionIds();
25413
- return ids.length > MAX_OUTLINES ? [] : ids;
25591
+ // Puts the halo on each selected label's glyphs. A class of its own rather
25592
+ // than the label_style mode's yellow one: that class is cleared on every
25593
+ // model update while the label tool is on, so the two would fight.
25594
+ function markAll(target, ids) {
25595
+ ids.forEach(function(id) {
25596
+ var nodes = findNodes(target, id);
25597
+ if (!nodes) return;
25598
+ nodes.text.classList.add('label-cue-marked');
25599
+ marked.push(nodes.text);
25600
+ });
25601
+ }
25602
+
25603
+ function clearMarks() {
25604
+ marked.forEach(function(node) {
25605
+ node.classList.remove('label-cue-marked');
25606
+ });
25607
+ marked = [];
25414
25608
  }
25415
25609
 
25416
25610
  function draw(target, id, className, withHandles) {
@@ -25484,7 +25678,8 @@
25484
25678
  var content;
25485
25679
  if (!text) return null;
25486
25680
  content = text.querySelector('textPath') || text;
25487
- return {symbol: symbol, content: content, pathId: getPathId(content)};
25681
+ return {symbol: symbol, text: text, content: content,
25682
+ pathId: getPathId(content)};
25488
25683
  }
25489
25684
 
25490
25685
  // The id of the baseline a path label is laid along, or null for an anchored
@@ -25581,12 +25776,183 @@
25581
25776
  if (g.parentNode) g.parentNode.removeChild(g);
25582
25777
  });
25583
25778
  groups = [];
25779
+ clearMarks();
25584
25780
  drawn = null;
25585
25781
  }
25586
25782
 
25587
25783
  return self;
25588
25784
  }
25589
25785
 
25786
+ // Selecting a group of labels by pointing at one of them: every label on the
25787
+ // layer, or the ones that share its text style, its colour or its symbol.
25788
+ //
25789
+ // Styling a group is something the panel could already do -- a control shows
25790
+ // nothing when the selected labels disagree, and writes to all of them -- but
25791
+ // there was no way to select more than a shift-click's worth. These are the
25792
+ // predicates the context menu offers, as pure functions so that they can be
25793
+ // tested without a map.
25794
+ //
25795
+ // See docs/development/label-tool-design.md.
25796
+
25797
+ // The properties that make up "the same text style": how the glyphs are drawn,
25798
+ // and nothing about where the text sits.
25799
+ //
25800
+ // Alignment, position, offsets and a path label's start offset are left out
25801
+ // deliberately. They are what the panel's other sections edit, and two labels
25802
+ // in the same font at the same size are in the same text style whether one of
25803
+ // them is centred and the other hangs north-east of its anchor -- which is
25804
+ // usually the difference between a label placed by hand and one that was not.
25805
+ //
25806
+ // css and class are in, because either can carry anything the properties above
25807
+ // carry and more: two labels are not in the same text style if one of them is
25808
+ // wearing a stylesheet the other is not.
25809
+ var TEXT_STYLE_FIELDS = [
25810
+ 'font-family', 'font-size', 'font-style', 'font-weight', 'font-stretch',
25811
+ 'letter-spacing', 'line-height', 'css', 'class'
25812
+ ];
25813
+
25814
+ // The colour of the text, and not its opacity: the item says colour, and a
25815
+ // label faded to 50% is the same colour as one that is not.
25816
+ var FILL_FIELDS = ['fill'];
25817
+
25818
+ // The shape of the symbol, and not its size or colour, for the same reason.
25819
+ var ICON_FIELDS = ['icon'];
25820
+
25821
+ // What a property means when a label does not carry it.
25822
+ //
25823
+ // Compared after this substitution, so that a label with font-size=12 and one
25824
+ // with no font-size match: they render identically, and the panel already
25825
+ // treats them as agreeing. Without it the commonest pair of labels on a map --
25826
+ // one the panel has touched and one it has not -- would count as different
25827
+ // text styles while looking the same.
25828
+ //
25829
+ // font-family is deliberately absent. An unset font is whatever the browser
25830
+ // resolves sans-serif to, which differs by machine and is a real difference
25831
+ // from a font the user named, so an unset font is its own value and matches
25832
+ // only another unset one. css, class and icon are absent because they have no
25833
+ // default: not having one is not the same as having a particular one.
25834
+ var STYLE_DEFAULTS = {
25835
+ 'font-size': 12,
25836
+ 'font-style': 'normal',
25837
+ 'font-weight': '400',
25838
+ 'letter-spacing': 0,
25839
+ fill: '#000000'
25840
+ };
25841
+
25842
+ // The items the "select" section offers, in menu order.
25843
+ //
25844
+ // needs: a property the pointed-at label must carry for the item to be worth
25845
+ // offering. "Same icon" on a label with no symbol would select every label
25846
+ // that has none, which is a question nobody asked and which "all labels"
25847
+ // nearly answers anyway.
25848
+ // always: offered whatever it matches, rather than only when it says something
25849
+ // the other items do not. See getLabelSelectActions().
25850
+ var SELECT_KINDS = [
25851
+ {name: 'all', label: 'all labels'},
25852
+ {name: 'text-style', label: 'same text style', fields: TEXT_STYLE_FIELDS,
25853
+ always: true},
25854
+ {name: 'fill', label: 'same fill color', fields: FILL_FIELDS},
25855
+ {name: 'icon', label: 'same icon', fields: ICON_FIELDS, needs: 'icon'}
25856
+ ];
25857
+
25858
+ // Every label on the layer, in ascending id order.
25859
+ //
25860
+ // Not every feature: a labels layer can hold plain points as well -- a dots
25861
+ // layer given label text by the point panel is one -- and a point is not
25862
+ // something the label panel can style.
25863
+ //
25864
+ // records: the layer's data records, or null
25865
+ // isLabel: (rec) -> whether a record is a label at all. Injected rather than
25866
+ // imported so that this module stays testable without the GUI's internals;
25867
+ // the caller passes internal.svg.featureIsLabel.
25868
+ function getAllLabelIds(records, isLabel) {
25869
+ var ids = [];
25870
+ var i;
25871
+ for (i = 0; records && i < records.length; i++) {
25872
+ if (isLabel(records[i])) ids.push(i);
25873
+ }
25874
+ return ids;
25875
+ }
25876
+
25877
+ // The labels that match label @id on @kind, in ascending id order.
25878
+ //
25879
+ // records: the layer's data records, or null
25880
+ // id: the label the predicate is taken from, which is always in the result
25881
+ // kind: 'all', 'text-style', 'fill' or 'icon'
25882
+ // isLabel: as getAllLabelIds()
25883
+ function getMatchingLabelIds(records, id, kind, isLabel) {
25884
+ var defn = findKind(kind);
25885
+ var rec = records && records[id];
25886
+ if (!defn || !rec || !isLabel(rec)) return [];
25887
+ return getAllLabelIds(records, isLabel).filter(function(i) {
25888
+ return !defn.fields || fieldsMatch(records[i], rec, defn.fields);
25889
+ });
25890
+ }
25891
+
25892
+ // The menu items worth offering for a right-click on label @id, as
25893
+ // [{name, label, ids}, ...].
25894
+ //
25895
+ // "All labels" and "same text style" are always offered, the first because it
25896
+ // asks nothing of the layer and the second because it is the item this section
25897
+ // is mostly for: styling the type of a map as a group. Offering it only when it
25898
+ // differs from "all labels" would make it come and go between one right-click
25899
+ // and the next, and its count is worth reading either way -- "same text style
25900
+ // 40" over a layer of 40 says the labels are uniform, which is a fact about the
25901
+ // layer and not a reason to hide the item.
25902
+ //
25903
+ // The rest are dropped rather than shown and disabled when:
25904
+ //
25905
+ // - They would select the label already pointed at and nothing else, which
25906
+ // would look like a way of narrowing the selection to one label -- a plain
25907
+ // click on that label already is one.
25908
+ // - They match every label on the layer, which "all labels" says more plainly.
25909
+ // A layer with one label on it gets no section at all: every item there would
25910
+ // select the label that was right-clicked, which clicking it already does.
25911
+ function getLabelSelectActions(records, id, isLabel) {
25912
+ var rec = records && records[id];
25913
+ var all = getMatchingLabelIds(records, id, 'all', isLabel);
25914
+ var out = [];
25915
+ if (!rec || all.length < 2) return out;
25916
+ SELECT_KINDS.forEach(function(defn) {
25917
+ var always = defn.name == 'all' || defn.always;
25918
+ var ids;
25919
+ if (defn.needs && !hasValue(rec[defn.needs])) return;
25920
+ ids = defn.name == 'all' ? all :
25921
+ getMatchingLabelIds(records, id, defn.name, isLabel);
25922
+ if (!always && (ids.length < 2 || ids.length === all.length)) return;
25923
+ out.push({name: defn.name, label: defn.label, ids: ids});
25924
+ });
25925
+ return out;
25926
+ }
25927
+
25928
+ function findKind(name) {
25929
+ var found = null;
25930
+ SELECT_KINDS.forEach(function(defn) {
25931
+ if (defn.name == name) found = defn;
25932
+ });
25933
+ return found;
25934
+ }
25935
+
25936
+ function fieldsMatch(a, b, fields) {
25937
+ return fields.every(function(field) {
25938
+ return styleValue(a, field) === styleValue(b, field);
25939
+ });
25940
+ }
25941
+
25942
+ // A property as it is compared: what the label carries, or what it means to
25943
+ // carry nothing, as a string so that 12 and '12' are one value.
25944
+ function styleValue(rec, field) {
25945
+ var val = rec ? rec[field] : null;
25946
+ if (!hasValue(val)) {
25947
+ val = field in STYLE_DEFAULTS ? STYLE_DEFAULTS[field] : '';
25948
+ }
25949
+ return String(val);
25950
+ }
25951
+
25952
+ function hasValue(val) {
25953
+ return !(val === undefined || val === null || val === '');
25954
+ }
25955
+
25590
25956
  // State of a label curve being drawn, and the operations the curvature tool
25591
25957
  // performs on it.
25592
25958
  //
@@ -25986,6 +26352,7 @@
25986
26352
  if (!active()) return;
25987
26353
  id = getRightClickedLabel(e);
25988
26354
  if (target && id > -1) {
26355
+ e.selectActions = getSelectActions(target, id);
25989
26356
  e.deleteFeature = getDeleteAction(target, id);
25990
26357
  // Nobody guesses that text is dragged across its own curve, and there is
25991
26358
  // no bracket drawn to suggest it, so the gesture has a second way in.
@@ -26014,6 +26381,43 @@
26014
26381
  return -1;
26015
26382
  }
26016
26383
 
26384
+ // The "select" items for a right-click on label @id: the labels that share
26385
+ // something with it, so that a style can be changed across a group without
26386
+ // shift-clicking every one of it.
26387
+ //
26388
+ // The predicate comes from the label pointed at rather than from whatever
26389
+ // happens to be selected -- a right-click deliberately leaves the selection
26390
+ // alone -- and running an item replaces the selection with what it matched.
26391
+ function getSelectActions(target, id) {
26392
+ var records = target.data ? target.data.getRecords() : null;
26393
+ return getLabelSelectActions(records, id, internal.svg.featureIsLabel)
26394
+ .map(function(action) {
26395
+ return {
26396
+ label: action.label,
26397
+ count: action.ids.length,
26398
+ run: function() { selectLabels(action.ids); }
26399
+ };
26400
+ });
26401
+ }
26402
+
26403
+ // A label being typed into is not a label being styled, so a group selection
26404
+ // ends the session -- which saves its text, as clicking away would. Closing
26405
+ // first and selecting second, because closing puts the label it was editing
26406
+ // back in the selection when the selection is empty.
26407
+ function selectLabels(ids) {
26408
+ if (editor.isOpen()) editor.close();
26409
+ hit.setSelectionIds(ids);
26410
+ }
26411
+
26412
+ // Cmd/Ctrl-A: the keyboard way to the menu's "all labels", for the commonest
26413
+ // of the group selections -- restyling a layer's labels as one.
26414
+ function selectAllLabels() {
26415
+ var target = hit.getHitTarget();
26416
+ var records = target && target.data ? target.data.getRecords() : null;
26417
+ var ids = getAllLabelIds(records, internal.svg.featureIsLabel);
26418
+ if (ids.length > 0) selectLabels(ids);
26419
+ }
26420
+
26017
26421
  // Deleting a label ends whatever was being done to it first: its text
26018
26422
  // session would otherwise write the text back to an id that now belongs to
26019
26423
  // the label that moved up into the gap, and the same shift is why the
@@ -26524,7 +26928,10 @@
26524
26928
  // ended.
26525
26929
  setMultilineAttribute(nodes.text, 'x', o.values.x);
26526
26930
  nodes.text.setAttribute('y', o.values.dy);
26527
- nodes.text.setAttribute('text-anchor', o.values['text-anchor']);
26931
+ // The anchor the label is drawn with, not the one the drag writes: on an
26932
+ // aligned label the two differ, and the stored one belongs with the stored
26933
+ // dx rather than with the x being previewed here.
26934
+ nodes.text.setAttribute('text-anchor', o.values.anchor);
26528
26935
  // The cue is drawn around the text rather than moved with it, so it has to
26529
26936
  // be rebuilt to follow -- one getBBox on one label per mouse move.
26530
26937
  selection.refresh(true);
@@ -26679,9 +27086,34 @@
26679
27086
  hoverTextId = id > -1 && (!hoverHandle || glyphsOutrankHandle(id)) ? id : -1;
26680
27087
  }
26681
27088
 
27089
+ // The exception is about the pointer being on the glyphs, not about the mode:
27090
+ // an offset label's anchor is out from under its text, and taking the handle
27091
+ // away there left it with no anchor handle at all -- dragging the symbol of a
27092
+ // label positioned ne slid its text instead of moving the label.
26682
27093
  function glyphsOutrankHandle(id) {
26683
27094
  return hoverHandle.id == id && labelTextIsDraggable(gui) &&
26684
- !isPathLabel(hit.getHitTarget(), id);
27095
+ !isPathLabel(hit.getHitTarget(), id) && pointerIsOverText(id);
27096
+ }
27097
+
27098
+ // Whether the pointer is within the box drawn around a label's text -- the
27099
+ // outline the selection cue draws, padding and all, so that the thing on
27100
+ // screen that says "this is the object" is the thing that takes the drag.
27101
+ //
27102
+ // From the last hover rather than from an event, because this is asked from
27103
+ // the hit control's 'change' as well, which arrives without a position; the
27104
+ // pointer has not moved since the hover that preceded it.
27105
+ function pointerIsOverText(id) {
27106
+ var target = hit.getHitTarget();
27107
+ var shapes = target && getDisplayShapes(target);
27108
+ var shp = shapes && shapes[id];
27109
+ var nodes = findLabelNodes(target, id);
27110
+ var box = nodes && !nodes.textPath ? measureNode(nodes.text) : null;
27111
+ var pix = hoverPoint && ext.translateCoords(hoverPoint[0], hoverPoint[1]);
27112
+ var p;
27113
+ if (!box || !pix || !shp) return false;
27114
+ p = getLabelSpacePoint(shp[0], {x: pix[0], y: pix[1]});
27115
+ return p.x >= box.x - BOX_PADDING && p.x <= box.x + box.width + BOX_PADDING &&
27116
+ p.y >= box.y - BOX_PADDING && p.y <= box.y + box.height + BOX_PADDING;
26685
27117
  }
26686
27118
 
26687
27119
  // The label a drag on the glyphs would act on, or -1. Only a selected label
@@ -26761,8 +27193,17 @@
26761
27193
  gui.keyboard.on('keydown', function(e) {
26762
27194
  if (!active()) return;
26763
27195
  // While a session is open the textarea has focus and handles its own keys;
26764
- // it stops propagation for the ones it acts on.
27196
+ // it stops propagation for the ones it acts on. Cmd-A there means select
27197
+ // the text being typed, which is the browser's to handle.
26765
27198
  if (editor.isOpen()) return;
27199
+ // Not while a curve is being drawn: that gesture owns the keyboard, and
27200
+ // selecting the layer in the middle of placing a label is not what the
27201
+ // keystroke can have meant.
27202
+ if (isSelectAll(e) && !drawingCurve()) {
27203
+ selectAllLabels();
27204
+ consume(e); // the default is to select the whole page
27205
+ return;
27206
+ }
26766
27207
  if (e.keyName == 'esc') {
26767
27208
  // One rung per press, innermost first: the curve being drawn, then the
26768
27209
  // selection, then the armed tool. The hit control's own escape handler
@@ -26793,6 +27234,16 @@
26793
27234
  }
26794
27235
  }, null, 10);
26795
27236
 
27237
+ // Cmd-A on a Mac, Ctrl-A elsewhere. Read from the key rather than the
27238
+ // keyCode, as undo and redo are.
27239
+ function isSelectAll(e) {
27240
+ var evt = e.originalEvent;
27241
+ if (!evt || !(evt.metaKey || evt.ctrlKey) || evt.shiftKey || evt.altKey) {
27242
+ return false;
27243
+ }
27244
+ return (evt.key || '').toLowerCase() == 'a';
27245
+ }
27246
+
26796
27247
  // Suppresses the key's default action as well as the rest of the GUI's
26797
27248
  // handlers. The default matters here: finishing a curve opens an editing
26798
27249
  // session and focuses its textarea, and without this the same Enter goes on
@@ -30801,8 +31252,7 @@
30801
31252
  var mapLayers = gui.container.findChild('.map-layers').node();
30802
31253
  var map = gui.container.findChild('.mshp-main-map');
30803
31254
  var toggle = gui.buttons.addButton('#frame-tool-icon')
30804
- .addClass('menu-btn preview-toggle')
30805
- .attr('title', 'Toggle map preview');
31255
+ .addClass('menu-btn preview-toggle');
30806
31256
  var readout = El('div')
30807
31257
  .addClass('preview-readout')
30808
31258
  .appendTo(map)
@@ -30922,7 +31372,11 @@
30922
31372
  var available = hasFrame();
30923
31373
  toggle.removeClass('disabled');
30924
31374
  toggle.classed('selected', available && self.isOn());
30925
- toggle.attr('title', available ? 'Toggle map preview' : 'Add map frame');
31375
+ // data-tooltip, not title: the CSS label in .nav-btn[data-tooltip] appears
31376
+ // without the browser's delay and matches the arrow menu's type.
31377
+ var label = available ? 'Toggle map preview' : 'Add map frame';
31378
+ toggle.attr('data-tooltip', label);
31379
+ toggle.attr('aria-label', label);
30926
31380
  toggle.attr('aria-disabled', 'false');
30927
31381
  toggle.attr('aria-pressed', available && self.isOn() ? 'true' : 'false');
30928
31382
  if (gui.map.isPreviewView()) {