ascii-tabs 0.10.6 → 0.11.3

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 (3) hide show
  1. package/README.md +23 -4
  2. package/ascii-tabs.js +72 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -62,12 +62,12 @@ Design notes: [spec](docs/spec-v0.1.md), [glossary](GLOSSARY.md), [ADRs](docs/ad
62
62
  ## Usage
63
63
 
64
64
  ```html
65
- <script type="module" src="https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.10.6/ascii-tabs.js"></script>
65
+ <script type="module" src="https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.11.3/ascii-tabs.js"></script>
66
66
 
67
67
  <ascii-tabs></ascii-tabs>
68
68
  ```
69
69
 
70
- A bare `<ascii-tabs>` is editable: a hint, one Sheet per Tab with copy and delete buttons, and an add button. Click a string and type the fret number (0–24); move with the arrow keys, Enter and Tab. Press Cmd+Right Arrow or Alt+Right Arrow to copy all six strings of the current column into the next column, replacing its contents, and move the cursor there. Press Cmd+0 or Alt+0 to fill only the empty cells of the current column with zeros, keeping existing frets and the cursor in place.
70
+ A bare `<ascii-tabs>` is editable: a hint, one Sheet per Tab with copy and delete buttons, and an add button. Click a string and type the fret number (0–24, or up to `max-fret`); move with the arrow keys, Enter and Tab. Press Cmd+Right Arrow or Alt+Right Arrow to copy all six strings of the current column into the next column, replacing its contents, and move the cursor there. Press Shift+Up Arrow or Shift+Down Arrow to re-finger the current note (or the whole selection) on the next String up or down that keeps its pitch, for example a 7 on String 5 becomes a 2 on String 4; nothing happens when no such position exists. Press Cmd+0 or Alt+0 to fill only the empty cells of the current column with zeros, keeping existing frets and the cursor in place.
71
71
 
72
72
  Drag from an empty cell or a blank area of a Sheet to select notes inside a rectangle. Drag any selected note to move the group, keeping its relative columns and strings; occupied destination cells are replaced. Cmd+Right Arrow or Alt+Right Arrow duplicates the selected group immediately after its last column and selects the duplicate. Cmd/Ctrl+C or the Sheet's copy button copies the selection as ASCII; Cmd/Ctrl+V pastes ASCII at the current column, preserving string positions. Delete or Backspace removes the selected notes, and Escape clears the selection.
73
73
 
@@ -409,7 +409,7 @@ E|-----3-|</pre>
409
409
  The copy button copies the same plain ASCII. `parse` and `format` are exported as pure functions:
410
410
 
411
411
  ```js
412
- import { parse, format } from 'https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.10.6/ascii-tabs.js';
412
+ import { parse, format } from 'https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.11.3/ascii-tabs.js';
413
413
 
414
414
  const tab = parse('1 -3--5-\n2 ------\n3 ------\n4 ------\n5 ------\n6 ------'); // one Tab: Column[]
415
415
  format(tab, { spacing: 2 }); // "1 -3--5-\n2 ------\n..."
@@ -573,14 +573,33 @@ Changing or removing the attribute updates the view immediately.
573
573
 
574
574
  ## String labels
575
575
 
576
+ `max-fret` (property `maxFret`) sets the highest Fret the editor accepts when typing or re-fingering with Shift+Up/Down Arrow. It defaults to `24`; use `max-fret="19"` for a classical guitar. Values outside 1–24 fall back to 24. It does not rewrite notes already in `value`.
577
+
578
+ ```html
579
+ <ascii-tabs max-fret="19"></ascii-tabs>
580
+ ```
581
+
576
582
  Strings are labelled `1`–`6` by default, on screen and in copied text. `labels="notes"` names them using `tuning` (standard tuning by default: `e B G D A E`, String 1 to 6). `labels="none"` hides the labels and uses their space for Columns. This also applies to copied text. The `labels` property accepts `"numbers"`, `"notes"`, or `"none"`; other values fall back to numbers. Parsing accepts all three styles whatever the attribute says.
577
583
 
578
584
  ```html
579
585
  <ascii-tabs></ascii-tabs> <!-- 1 -3--5- -->
580
- <ascii-tabs labels="notes"></ascii-tabs> <!-- e|-3--5- -->
586
+ <ascii-tabs labels="notes"></ascii-tabs> <!-- e -3--5- -->
581
587
  <ascii-tabs labels="none"></ascii-tabs> <!-- -3--5- -->
582
588
  ```
583
589
 
590
+ String labels have no bars by default. Add the boolean `string-bars` attribute
591
+ (or set `element.stringBars = true`) to draw a `|` at the start of each String,
592
+ independently of the labels. The bars also appear in copied ASCII. Remove the
593
+ attribute or set the property to `false` to hide them. With labels, the bar
594
+ replaces the separating space, so `e|` and `1|` occupy two characters.
595
+
596
+ ```html
597
+ <ascii-tabs labels="notes" string-bars></ascii-tabs> <!-- e|-3--5- -->
598
+ <ascii-tabs labels="none" string-bars></ascii-tabs> <!-- |-3--5- -->
599
+ ```
600
+
601
+ The standalone formatter accepts the same option: `format(tab, { stringBars: true })`.
602
+
584
603
  ## Tool position
585
604
 
586
605
  A Sheet's tools (copy, delete) stand in a column to the right of the Staves by default, and the Staves leave room for them. `tools="top"` lines them up in a row above the first String instead. Also available as the `tools` property.
package/ascii-tabs.js CHANGED
@@ -23,14 +23,16 @@ const normalizeTuning = value => {
23
23
  : [...STANDARD_TUNING];
24
24
  };
25
25
  const labelStyle = value => (value === 'notes' || value === 'none' ? value : 'numbers');
26
- const stringLabels = (labels, tuning) => {
26
+ const stringLabels = (labels, tuning, stringBars = false) => {
27
27
  if (labelStyle(labels) === 'none') return Array(STRING_COUNT).fill('');
28
- if (labelStyle(labels) === 'numbers') return ['1 ', '2 ', '3 ', '4 ', '5 ', '6 '];
29
- const names = tuning.map((name, s) => `${s === 0 ? name.toLowerCase() : name}|`);
30
- const width = Math.max(...names.map(name => name.length));
28
+ const names = labelStyle(labels) === 'numbers'
29
+ ? ['1', '2', '3', '4', '5', '6']
30
+ : tuning.map((name, s) => s === 0 ? name.toLowerCase() : name);
31
+ const width = Math.max(...names.map(name => name.length)) + (stringBars ? 0 : 1);
31
32
  return names.map(name => name.padEnd(width, ' '));
32
33
  };
33
- const labelWidth = (labels, tuning) => stringLabels(labels, tuning)[0].length;
34
+ const labelWidth = (labels, tuning, stringBars = false) => stringLabels(labels, tuning, stringBars)[0].length;
35
+ const prefixWidth = (labels, tuning, stringBars) => labelWidth(labels, tuning, stringBars) + (stringBars ? 1 : 0);
34
36
  const notationStyle = value => (value === 'letters' || value === 'solfege' ? value : 'frets');
35
37
  const NOTE_NAMES = {
36
38
  letters: ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'],
@@ -176,22 +178,22 @@ function layout(tab, spacing, capacity, notation, tuning, sizes = []) {
176
178
  }
177
179
 
178
180
  // One Tab as plain ASCII, every Staff cut right after its last Column
179
- function format(tab, { spacing = DEFAULT_SPACING, labels = 'numbers', notation = 'frets', tuning = STANDARD_TUNING, width = Infinity, annotations = [], annotationFit = 'overflow' } = {}) {
181
+ function format(tab, { spacing = DEFAULT_SPACING, labels = 'numbers', stringBars = false, notation = 'frets', tuning = STANDARD_TUNING, width = Infinity, annotations = [], annotationFit = 'overflow' } = {}) {
180
182
  tuning = normalizeTuning(tuning);
181
183
  annotations = normalizeAnnotations([annotations], [tab])[0] ?? [];
182
- const names = stringLabels(labels, tuning);
184
+ const names = stringLabels(labels, tuning, stringBars);
183
185
  const columns = tab.slice(0, lastContent(tab, annotations) + 1);
184
186
  if (!columns.length) return '';
185
187
  const lead = '-'.repeat(leadingDashes(spacing));
186
188
  const trail = '-'.repeat(spacing - lead.length);
187
- const capacity = width - labelWidth(labels, tuning);
189
+ const capacity = width - prefixWidth(labels, tuning, stringBars);
188
190
  const sizes = annotationSizes(columns, annotations, normalizeAnnotationFit(annotationFit), { spacing, notation, tuning, capacity });
189
191
  return layout(columns, spacing, capacity, notation, tuning, sizes)
190
192
  .map(([start, end]) => {
191
193
  const offsets = columnOffsets(columns, start, end, spacing, notation, tuning, sizes);
192
- const lines = annotationLines(annotations, start, end, offsets, labelWidth(labels, tuning));
194
+ const lines = annotationLines(annotations, start, end, offsets, prefixWidth(labels, tuning, stringBars));
193
195
  for (let s = 0; s < STRING_COUNT; s++) {
194
- let line = names[s];
196
+ let line = names[s] + (stringBars ? '|' : '');
195
197
  for (let c = start; c < end; c++) {
196
198
  const digits = sizes[c] ?? columnDigits(columns[c], notation, tuning);
197
199
  const fret = fretText(columns[c][s], s, notation, tuning);
@@ -229,7 +231,7 @@ function parse(ascii) {
229
231
  continue;
230
232
  }
231
233
  const label = LABEL_PATTERN.exec(line);
232
- if (!label && !/^\s*-/.test(line)) return fail(`unexpected line "${line.trim()}"`);
234
+ if (!label && !/^\s*[-|]/.test(line)) return fail(`unexpected line "${line.trim()}"`);
233
235
  const body = label ? line.slice(label[0].length) : line;
234
236
  if (!/^[-|\d\s]*$/.test(body.replace(/\(\d*\)/g, ''))) return fail(`unexpected characters in "${line.trim()}"`);
235
237
  group.push(body);
@@ -453,6 +455,7 @@ ascii-tabs .ascii-tabs-text-editor:focus { border-bottom-color: var(--ascii-tabs
453
455
 
454
456
  ascii-tabs .ascii-tabs-line { display: block; height: 1.5em; line-height: 1.5em; }
455
457
  ascii-tabs .ascii-tabs-label { color: var(--ascii-tabs-dash); }
458
+ ascii-tabs .ascii-tabs-bar { display: inline-block; flex: 0 0 1ch; }
456
459
  ascii-tabs .ascii-tabs-string { color: var(--ascii-tabs-string); }
457
460
  ascii-tabs .ascii-tabs-cell {
458
461
  display: inline-block; height: 1.5em;
@@ -785,7 +788,7 @@ class AsciiTabs extends Base {
785
788
  }
786
789
  }
787
790
 
788
- static observedAttributes = ['readonly', 'spacing', 'labels', 'notation', 'tuning', 'lang', 'tools', 'annotations-editable', 'titles-editable', 'annotation-fit'];
791
+ static observedAttributes = ['readonly', 'spacing', 'labels', 'string-bars', 'notation', 'tuning', 'lang', 'tools', 'annotations-editable', 'titles-editable', 'annotation-fit', 'max-fret'];
789
792
 
790
793
  // Strings: the `lang` preset (English by default) with `messages` merged over it
791
794
  get #text() {
@@ -830,6 +833,14 @@ class AsciiTabs extends Base {
830
833
  this.setAttribute('labels', labelStyle(value));
831
834
  }
832
835
 
836
+ get stringBars() {
837
+ return this.hasAttribute('string-bars');
838
+ }
839
+
840
+ set stringBars(on) {
841
+ this.toggleAttribute('string-bars', Boolean(on));
842
+ }
843
+
833
844
  get notation() {
834
845
  return notationStyle(this.getAttribute('notation'));
835
846
  }
@@ -846,6 +857,16 @@ class AsciiTabs extends Base {
846
857
  this.setAttribute('tuning', normalizeTuning(value).join(' '));
847
858
  }
848
859
 
860
+ // Highest Fret the editor accepts: 24 by default, lower for instruments with fewer frets
861
+ get maxFret() {
862
+ const n = Math.floor(Number(this.getAttribute('max-fret')));
863
+ return Number.isFinite(n) && n >= 1 && n <= MAX_FRET ? n : MAX_FRET;
864
+ }
865
+
866
+ set maxFret(value) {
867
+ this.setAttribute('max-fret', value);
868
+ }
869
+
849
870
  get tools() {
850
871
  return toolsPosition(this.getAttribute('tools'));
851
872
  }
@@ -881,7 +902,7 @@ class AsciiTabs extends Base {
881
902
 
882
903
  attributeChangedCallback(name) {
883
904
  if (name === 'spacing') return this.#applySpacing();
884
- if (name === 'labels' || name === 'tuning') {
905
+ if (name === 'labels' || name === 'string-bars' || name === 'tuning') {
885
906
  this.#measure();
886
907
  return this.#render();
887
908
  }
@@ -911,7 +932,7 @@ class AsciiTabs extends Base {
911
932
  return;
912
933
  }
913
934
  // Properties set before the element was upgraded shadow the accessors
914
- for (const name of ['value', 'columnLayout', 'annotations', 'tabTitles', 'annotationsEditable', 'titlesEditable', 'annotationFit', 'readonly', 'spacing', 'labels', 'notation', 'tuning', 'tools', 'variant', 'theme', 'messages']) {
935
+ for (const name of ['value', 'columnLayout', 'annotations', 'tabTitles', 'annotationsEditable', 'titlesEditable', 'annotationFit', 'readonly', 'spacing', 'labels', 'stringBars', 'notation', 'tuning', 'maxFret', 'tools', 'variant', 'theme', 'messages']) {
915
936
  if (Object.hasOwn(this, name)) {
916
937
  const own = this[name];
917
938
  delete this[name];
@@ -1110,7 +1131,7 @@ class AsciiTabs extends Base {
1110
1131
  if (!charWidth) return;
1111
1132
  this.#scheduleOverflow();
1112
1133
  const chars = Math.floor(width / charWidth);
1113
- const capacity = Math.max(chars - labelWidth(this.labels, this.tuning), 4 * (this.#spacing + 1));
1134
+ const capacity = Math.max(chars - prefixWidth(this.labels, this.tuning, this.stringBars), 4 * (this.#spacing + 1));
1114
1135
  const signature = `${width}:${charWidth}`;
1115
1136
  const annotationResized = this.#annotationMeasure !== signature;
1116
1137
  this.#annotationMeasure = signature;
@@ -1163,8 +1184,8 @@ class AsciiTabs extends Base {
1163
1184
  this.#annotationResizeObserver?.disconnect();
1164
1185
  const annotationPositions = [];
1165
1186
  const labels = this.labels, notation = this.notation, tuning = this.tuning;
1166
- const gutter = labelWidth(labels, tuning);
1167
- const names = stringLabels(labels, tuning);
1187
+ const gutter = labelWidth(labels, tuning, this.stringBars);
1188
+ const names = stringLabels(labels, tuning, this.stringBars);
1168
1189
  this.#titleEls.forEach((title, t) => {
1169
1190
  title.textContent = this.#tabTitles[t] ?? '';
1170
1191
  title.hidden = !title.textContent && !(this.titlesEditable && !this.readonly);
@@ -1188,6 +1209,7 @@ class AsciiTabs extends Base {
1188
1209
  for (let s = 0; s < STRING_COUNT; s++) {
1189
1210
  html += '<span class="ascii-tabs-line">';
1190
1211
  if (labels !== 'none') html += `<span class="ascii-tabs-label" style="flex-basis:${gutter}ch">${names[s]}</span>`;
1212
+ if (this.stringBars) html += '<span class="ascii-tabs-bar ascii-tabs-string" aria-hidden="true">|</span>';
1191
1213
  if (geometry) html += `<span class="ascii-tabs-area" data-t="${t}" data-s="${s}" style="width:${geometry.width}px">`;
1192
1214
  for (let c = start; c < end; c++) {
1193
1215
  const isCur = this.#cur && this.#cur.t === t && this.#cur.c === c && this.#cur.s === s;
@@ -1239,7 +1261,7 @@ class AsciiTabs extends Base {
1239
1261
  const annotations = entries.filter(a => a.column >= start && a.column < end && (a.html ?? a.text));
1240
1262
  if (!annotations.length && !(this.annotationsEditable && !this.readonly)) return;
1241
1263
  const band = this.#el('div', 'ascii-tabs-annotations');
1242
- if (geometry) band.style.width = `calc(${geometry.width}px + ${labelWidth(this.labels, this.tuning)}ch)`;
1264
+ if (geometry) band.style.width = `calc(${geometry.width}px + ${prefixWidth(this.labels, this.tuning, this.stringBars)}ch)`;
1243
1265
  staff.prepend(band);
1244
1266
  const shown = shownAnnotations(entries);
1245
1267
  for (const entry of annotations) {
@@ -1653,6 +1675,33 @@ class AsciiTabs extends Base {
1653
1675
  this.#goTo(t, c, s + d);
1654
1676
  }
1655
1677
 
1678
+ // Shift+Up/Down: re-finger the focused or selected notes on the nearest String that keeps their
1679
+ // pitch and puts every one of them within 0..maxFret. Strings are assumed to descend in pitch.
1680
+ #refinger(d) {
1681
+ const notes = (this.#selection.length ? this.#selection : [this.#cur]).filter(p => this.#tabs[p.t][p.c]?.[p.s] != null);
1682
+ if (!notes.length) return;
1683
+ const tuning = this.tuning;
1684
+ const depth = [0];
1685
+ for (let s = 1; s < STRING_COUNT; s++) depth[s] = depth[s - 1] + ((tuningPitch(tuning[s - 1]) - tuningPitch(tuning[s]) + 11) % 12 + 1);
1686
+ for (let step = 1; step < STRING_COUNT; step++) {
1687
+ const moved = notes.map(p => {
1688
+ const to = p.s + d * step;
1689
+ const note = this.#tabs[p.t][p.c][p.s];
1690
+ return { ...p, to, fret: fretValue(note) + depth[to] - depth[p.s], note };
1691
+ });
1692
+ if (moved.some(m => m.to < 0 || m.to >= STRING_COUNT || !isFret(m.fret) || m.fret > this.maxFret)) {
1693
+ if (moved.some(m => m.to < 0 || m.to >= STRING_COUNT)) return;
1694
+ continue;
1695
+ }
1696
+ for (const m of moved) this.#setFret(m.t, m.c, m.s, null);
1697
+ for (const m of moved) this.#setFret(m.t, m.c, m.to, typeof m.note === 'object' ? { ...m.note, fret: m.fret } : m.fret);
1698
+ const { t, c, s } = this.#cur;
1699
+ if (this.#selection.length) this.#selection = moved.map(m => ({ t: m.t, c: m.c, s: m.to }));
1700
+ this.#goTo(t, c, s + d * step, this.#selection.length > 0);
1701
+ return this.#emitChange(this.#selection.length ? null : this.#cur);
1702
+ }
1703
+ }
1704
+
1656
1705
  // Typing writes to the value straight away, so `change` follows every keystroke
1657
1706
  #edit(draft, keepParentheses = true) {
1658
1707
  this.#selection = [];
@@ -1670,7 +1719,7 @@ class AsciiTabs extends Base {
1670
1719
  #typeDigit(d) {
1671
1720
  if (this.#draft === null || this.#draft === '') return this.#edit(d);
1672
1721
  const next = this.#draft + d;
1673
- if (/^(0|[1-9][0-9]?)$/.test(next) && Number(next) <= MAX_FRET) this.#edit(next);
1722
+ if (/^(0|[1-9][0-9]?)$/.test(next) && Number(next) <= this.maxFret) this.#edit(next);
1674
1723
  }
1675
1724
 
1676
1725
  #parenthesize() {
@@ -1751,10 +1800,10 @@ class AsciiTabs extends Base {
1751
1800
  return this.#moveHorizontal(-1);
1752
1801
  case 'ArrowUp':
1753
1802
  e.preventDefault();
1754
- return this.#moveVertical(-1);
1803
+ return e.shiftKey ? this.#refinger(-1) : this.#moveVertical(-1);
1755
1804
  case 'ArrowDown':
1756
1805
  e.preventDefault();
1757
- return this.#moveVertical(1);
1806
+ return e.shiftKey ? this.#refinger(1) : this.#moveVertical(1);
1758
1807
  case 'Backspace': {
1759
1808
  e.preventDefault();
1760
1809
  const { t, c, s } = this.#cur;
@@ -1786,7 +1835,7 @@ class AsciiTabs extends Base {
1786
1835
  const last = Math.max(...this.#selection.map(p => p.c));
1787
1836
  const columns = Array.from({ length: last - first + 1 }, emptyColumn);
1788
1837
  for (const { t, c, s } of this.#selection) columns[c - first][s] = this.#tabs[t][c][s];
1789
- return format(columns, { spacing: this.#spacing, labels: this.labels, notation: this.notation, tuning: this.tuning });
1838
+ return format(columns, { spacing: this.#spacing, labels: this.labels, stringBars: this.stringBars, notation: this.notation, tuning: this.tuning });
1790
1839
  }
1791
1840
 
1792
1841
  #onCopy = e => {
@@ -1975,7 +2024,7 @@ class AsciiTabs extends Base {
1975
2024
  async copy(index = 0) {
1976
2025
  const tab = this.#tabs[index];
1977
2026
  if (!tab) return;
1978
- await copyText(this.#selection.length && this.#selection[0].t === index ? this.#selectionText() : format(tab, { spacing: this.#spacing, labels: this.labels, notation: this.notation, tuning: this.tuning, width: this.#capacity + labelWidth(this.labels, this.tuning), annotations: this.#annotations[index], annotationFit: this.#annotationFit() }));
2027
+ await copyText(this.#selection.length && this.#selection[0].t === index ? this.#selectionText() : format(tab, { spacing: this.#spacing, labels: this.labels, stringBars: this.stringBars, notation: this.notation, tuning: this.tuning, width: this.#capacity + prefixWidth(this.labels, this.tuning, this.stringBars), annotations: this.#annotations[index], annotationFit: this.#annotationFit() }));
1979
2028
  const button = this.#sheetEls[index]?.querySelector('.ascii-tabs-copy');
1980
2029
  if (!button) return;
1981
2030
  this.#flashCopied(button);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ascii-tabs",
3
- "version": "0.10.6",
3
+ "version": "0.11.3",
4
4
  "description": "A zero-dependency web component to write and show guitar tabs as plain ASCII.",
5
5
  "type": "module",
6
6
  "main": "./ascii-tabs.js",