ascii-tabs 0.10.3 → 0.11.2

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 +30 -4
  2. package/ascii-tabs.js +64 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,6 +24,14 @@ import { parse, format } from 'ascii-tabs';
24
24
 
25
25
  No bundler? Load it from a CDN instead, see [Usage](#usage). Full documentation and a live, editable demo: **https://libasoles.github.io/ascii-tabs/**
26
26
 
27
+ ## Used in
28
+
29
+ `ascii-tabs` runs in production on [acordesdeguitarra.com.ar](https://acordesdeguitarra.com.ar/en):
30
+
31
+ - [Tabs for arpeggios](https://acordesdeguitarra.com.ar/en/articles/tabs-for-arpeggios): an article with arpeggio tabs.
32
+ - [Create guitar tabs](https://acordesdeguitarra.com.ar/en/create-guitar-tabs): write your own tabs online.
33
+ - [Carcassi preludes](https://acordesdeguitarra.com.ar/en/articles/carcassi-preludes): integrated with sheet music (partituras).
34
+
27
35
  ## See it in action
28
36
 
29
37
  ### Edit and organize tabs
@@ -54,12 +62,12 @@ Design notes: [spec](docs/spec-v0.1.md), [glossary](GLOSSARY.md), [ADRs](docs/ad
54
62
  ## Usage
55
63
 
56
64
  ```html
57
- <script type="module" src="https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.10.3/ascii-tabs.js"></script>
65
+ <script type="module" src="https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.11.2/ascii-tabs.js"></script>
58
66
 
59
67
  <ascii-tabs></ascii-tabs>
60
68
  ```
61
69
 
62
- 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.
63
71
 
64
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.
65
73
 
@@ -401,7 +409,7 @@ E|-----3-|</pre>
401
409
  The copy button copies the same plain ASCII. `parse` and `format` are exported as pure functions:
402
410
 
403
411
  ```js
404
- import { parse, format } from 'https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.10.3/ascii-tabs.js';
412
+ import { parse, format } from 'https://cdn.jsdelivr.net/gh/libasoles/ascii-tabs@0.11.2/ascii-tabs.js';
405
413
 
406
414
  const tab = parse('1 -3--5-\n2 ------\n3 ------\n4 ------\n5 ------\n6 ------'); // one Tab: Column[]
407
415
  format(tab, { spacing: 2 }); // "1 -3--5-\n2 ------\n..."
@@ -565,14 +573,32 @@ Changing or removing the attribute updates the view immediately.
565
573
 
566
574
  ## String labels
567
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
+
568
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.
569
583
 
570
584
  ```html
571
585
  <ascii-tabs></ascii-tabs> <!-- 1 -3--5- -->
572
- <ascii-tabs labels="notes"></ascii-tabs> <!-- e|-3--5- -->
586
+ <ascii-tabs labels="notes"></ascii-tabs> <!-- e -3--5- -->
573
587
  <ascii-tabs labels="none"></ascii-tabs> <!-- -3--5- -->
574
588
  ```
575
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.
594
+
595
+ ```html
596
+ <ascii-tabs labels="notes" string-bars></ascii-tabs> <!-- e |-3--5- -->
597
+ <ascii-tabs labels="none" string-bars></ascii-tabs> <!-- |-3--5- -->
598
+ ```
599
+
600
+ The standalone formatter accepts the same option: `format(tab, { stringBars: true })`.
601
+
576
602
  ## Tool position
577
603
 
578
604
  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
@@ -26,11 +26,12 @@ const labelStyle = value => (value === 'notes' || value === 'none' ? value : 'nu
26
26
  const stringLabels = (labels, tuning) => {
27
27
  if (labelStyle(labels) === 'none') return Array(STRING_COUNT).fill('');
28
28
  if (labelStyle(labels) === 'numbers') return ['1 ', '2 ', '3 ', '4 ', '5 ', '6 '];
29
- const names = tuning.map((name, s) => `${s === 0 ? name.toLowerCase() : name}|`);
29
+ const names = tuning.map((name, s) => `${s === 0 ? name.toLowerCase() : name} `);
30
30
  const width = Math.max(...names.map(name => name.length));
31
31
  return names.map(name => name.padEnd(width, ' '));
32
32
  };
33
33
  const labelWidth = (labels, tuning) => stringLabels(labels, tuning)[0].length;
34
+ const prefixWidth = (labels, tuning, stringBars) => labelWidth(labels, tuning) + (stringBars ? 1 : 0);
34
35
  const notationStyle = value => (value === 'letters' || value === 'solfege' ? value : 'frets');
35
36
  const NOTE_NAMES = {
36
37
  letters: ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'],
@@ -176,7 +177,7 @@ function layout(tab, spacing, capacity, notation, tuning, sizes = []) {
176
177
  }
177
178
 
178
179
  // 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' } = {}) {
180
+ function format(tab, { spacing = DEFAULT_SPACING, labels = 'numbers', stringBars = false, notation = 'frets', tuning = STANDARD_TUNING, width = Infinity, annotations = [], annotationFit = 'overflow' } = {}) {
180
181
  tuning = normalizeTuning(tuning);
181
182
  annotations = normalizeAnnotations([annotations], [tab])[0] ?? [];
182
183
  const names = stringLabels(labels, tuning);
@@ -184,14 +185,14 @@ function format(tab, { spacing = DEFAULT_SPACING, labels = 'numbers', notation =
184
185
  if (!columns.length) return '';
185
186
  const lead = '-'.repeat(leadingDashes(spacing));
186
187
  const trail = '-'.repeat(spacing - lead.length);
187
- const capacity = width - labelWidth(labels, tuning);
188
+ const capacity = width - prefixWidth(labels, tuning, stringBars);
188
189
  const sizes = annotationSizes(columns, annotations, normalizeAnnotationFit(annotationFit), { spacing, notation, tuning, capacity });
189
190
  return layout(columns, spacing, capacity, notation, tuning, sizes)
190
191
  .map(([start, end]) => {
191
192
  const offsets = columnOffsets(columns, start, end, spacing, notation, tuning, sizes);
192
- const lines = annotationLines(annotations, start, end, offsets, labelWidth(labels, tuning));
193
+ const lines = annotationLines(annotations, start, end, offsets, prefixWidth(labels, tuning, stringBars));
193
194
  for (let s = 0; s < STRING_COUNT; s++) {
194
- let line = names[s];
195
+ let line = names[s] + (stringBars ? '|' : '');
195
196
  for (let c = start; c < end; c++) {
196
197
  const digits = sizes[c] ?? columnDigits(columns[c], notation, tuning);
197
198
  const fret = fretText(columns[c][s], s, notation, tuning);
@@ -229,7 +230,7 @@ function parse(ascii) {
229
230
  continue;
230
231
  }
231
232
  const label = LABEL_PATTERN.exec(line);
232
- if (!label && !/^\s*-/.test(line)) return fail(`unexpected line "${line.trim()}"`);
233
+ if (!label && !/^\s*[-|]/.test(line)) return fail(`unexpected line "${line.trim()}"`);
233
234
  const body = label ? line.slice(label[0].length) : line;
234
235
  if (!/^[-|\d\s]*$/.test(body.replace(/\(\d*\)/g, ''))) return fail(`unexpected characters in "${line.trim()}"`);
235
236
  group.push(body);
@@ -453,6 +454,7 @@ ascii-tabs .ascii-tabs-text-editor:focus { border-bottom-color: var(--ascii-tabs
453
454
 
454
455
  ascii-tabs .ascii-tabs-line { display: block; height: 1.5em; line-height: 1.5em; }
455
456
  ascii-tabs .ascii-tabs-label { color: var(--ascii-tabs-dash); }
457
+ ascii-tabs .ascii-tabs-bar { display: inline-block; flex: 0 0 1ch; }
456
458
  ascii-tabs .ascii-tabs-string { color: var(--ascii-tabs-string); }
457
459
  ascii-tabs .ascii-tabs-cell {
458
460
  display: inline-block; height: 1.5em;
@@ -785,7 +787,7 @@ class AsciiTabs extends Base {
785
787
  }
786
788
  }
787
789
 
788
- static observedAttributes = ['readonly', 'spacing', 'labels', 'notation', 'tuning', 'lang', 'tools', 'annotations-editable', 'titles-editable', 'annotation-fit'];
790
+ static observedAttributes = ['readonly', 'spacing', 'labels', 'string-bars', 'notation', 'tuning', 'lang', 'tools', 'annotations-editable', 'titles-editable', 'annotation-fit', 'max-fret'];
789
791
 
790
792
  // Strings: the `lang` preset (English by default) with `messages` merged over it
791
793
  get #text() {
@@ -830,6 +832,14 @@ class AsciiTabs extends Base {
830
832
  this.setAttribute('labels', labelStyle(value));
831
833
  }
832
834
 
835
+ get stringBars() {
836
+ return this.hasAttribute('string-bars');
837
+ }
838
+
839
+ set stringBars(on) {
840
+ this.toggleAttribute('string-bars', Boolean(on));
841
+ }
842
+
833
843
  get notation() {
834
844
  return notationStyle(this.getAttribute('notation'));
835
845
  }
@@ -846,6 +856,16 @@ class AsciiTabs extends Base {
846
856
  this.setAttribute('tuning', normalizeTuning(value).join(' '));
847
857
  }
848
858
 
859
+ // Highest Fret the editor accepts: 24 by default, lower for instruments with fewer frets
860
+ get maxFret() {
861
+ const n = Math.floor(Number(this.getAttribute('max-fret')));
862
+ return Number.isFinite(n) && n >= 1 && n <= MAX_FRET ? n : MAX_FRET;
863
+ }
864
+
865
+ set maxFret(value) {
866
+ this.setAttribute('max-fret', value);
867
+ }
868
+
849
869
  get tools() {
850
870
  return toolsPosition(this.getAttribute('tools'));
851
871
  }
@@ -881,7 +901,7 @@ class AsciiTabs extends Base {
881
901
 
882
902
  attributeChangedCallback(name) {
883
903
  if (name === 'spacing') return this.#applySpacing();
884
- if (name === 'labels' || name === 'tuning') {
904
+ if (name === 'labels' || name === 'string-bars' || name === 'tuning') {
885
905
  this.#measure();
886
906
  return this.#render();
887
907
  }
@@ -911,7 +931,7 @@ class AsciiTabs extends Base {
911
931
  return;
912
932
  }
913
933
  // 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']) {
934
+ for (const name of ['value', 'columnLayout', 'annotations', 'tabTitles', 'annotationsEditable', 'titlesEditable', 'annotationFit', 'readonly', 'spacing', 'labels', 'stringBars', 'notation', 'tuning', 'maxFret', 'tools', 'variant', 'theme', 'messages']) {
915
935
  if (Object.hasOwn(this, name)) {
916
936
  const own = this[name];
917
937
  delete this[name];
@@ -1110,7 +1130,7 @@ class AsciiTabs extends Base {
1110
1130
  if (!charWidth) return;
1111
1131
  this.#scheduleOverflow();
1112
1132
  const chars = Math.floor(width / charWidth);
1113
- const capacity = Math.max(chars - labelWidth(this.labels, this.tuning), 4 * (this.#spacing + 1));
1133
+ const capacity = Math.max(chars - prefixWidth(this.labels, this.tuning, this.stringBars), 4 * (this.#spacing + 1));
1114
1134
  const signature = `${width}:${charWidth}`;
1115
1135
  const annotationResized = this.#annotationMeasure !== signature;
1116
1136
  this.#annotationMeasure = signature;
@@ -1188,6 +1208,7 @@ class AsciiTabs extends Base {
1188
1208
  for (let s = 0; s < STRING_COUNT; s++) {
1189
1209
  html += '<span class="ascii-tabs-line">';
1190
1210
  if (labels !== 'none') html += `<span class="ascii-tabs-label" style="flex-basis:${gutter}ch">${names[s]}</span>`;
1211
+ if (this.stringBars) html += '<span class="ascii-tabs-bar ascii-tabs-string" aria-hidden="true">|</span>';
1191
1212
  if (geometry) html += `<span class="ascii-tabs-area" data-t="${t}" data-s="${s}" style="width:${geometry.width}px">`;
1192
1213
  for (let c = start; c < end; c++) {
1193
1214
  const isCur = this.#cur && this.#cur.t === t && this.#cur.c === c && this.#cur.s === s;
@@ -1239,7 +1260,7 @@ class AsciiTabs extends Base {
1239
1260
  const annotations = entries.filter(a => a.column >= start && a.column < end && (a.html ?? a.text));
1240
1261
  if (!annotations.length && !(this.annotationsEditable && !this.readonly)) return;
1241
1262
  const band = this.#el('div', 'ascii-tabs-annotations');
1242
- if (geometry) band.style.width = `calc(${geometry.width}px + ${labelWidth(this.labels, this.tuning)}ch)`;
1263
+ if (geometry) band.style.width = `calc(${geometry.width}px + ${prefixWidth(this.labels, this.tuning, this.stringBars)}ch)`;
1243
1264
  staff.prepend(band);
1244
1265
  const shown = shownAnnotations(entries);
1245
1266
  for (const entry of annotations) {
@@ -1653,6 +1674,33 @@ class AsciiTabs extends Base {
1653
1674
  this.#goTo(t, c, s + d);
1654
1675
  }
1655
1676
 
1677
+ // Shift+Up/Down: re-finger the focused or selected notes on the nearest String that keeps their
1678
+ // pitch and puts every one of them within 0..maxFret. Strings are assumed to descend in pitch.
1679
+ #refinger(d) {
1680
+ const notes = (this.#selection.length ? this.#selection : [this.#cur]).filter(p => this.#tabs[p.t][p.c]?.[p.s] != null);
1681
+ if (!notes.length) return;
1682
+ const tuning = this.tuning;
1683
+ const depth = [0];
1684
+ for (let s = 1; s < STRING_COUNT; s++) depth[s] = depth[s - 1] + ((tuningPitch(tuning[s - 1]) - tuningPitch(tuning[s]) + 11) % 12 + 1);
1685
+ for (let step = 1; step < STRING_COUNT; step++) {
1686
+ const moved = notes.map(p => {
1687
+ const to = p.s + d * step;
1688
+ const note = this.#tabs[p.t][p.c][p.s];
1689
+ return { ...p, to, fret: fretValue(note) + depth[to] - depth[p.s], note };
1690
+ });
1691
+ if (moved.some(m => m.to < 0 || m.to >= STRING_COUNT || !isFret(m.fret) || m.fret > this.maxFret)) {
1692
+ if (moved.some(m => m.to < 0 || m.to >= STRING_COUNT)) return;
1693
+ continue;
1694
+ }
1695
+ for (const m of moved) this.#setFret(m.t, m.c, m.s, null);
1696
+ for (const m of moved) this.#setFret(m.t, m.c, m.to, typeof m.note === 'object' ? { ...m.note, fret: m.fret } : m.fret);
1697
+ const { t, c, s } = this.#cur;
1698
+ if (this.#selection.length) this.#selection = moved.map(m => ({ t: m.t, c: m.c, s: m.to }));
1699
+ this.#goTo(t, c, s + d * step, this.#selection.length > 0);
1700
+ return this.#emitChange(this.#selection.length ? null : this.#cur);
1701
+ }
1702
+ }
1703
+
1656
1704
  // Typing writes to the value straight away, so `change` follows every keystroke
1657
1705
  #edit(draft, keepParentheses = true) {
1658
1706
  this.#selection = [];
@@ -1670,7 +1718,7 @@ class AsciiTabs extends Base {
1670
1718
  #typeDigit(d) {
1671
1719
  if (this.#draft === null || this.#draft === '') return this.#edit(d);
1672
1720
  const next = this.#draft + d;
1673
- if (/^(0|[1-9][0-9]?)$/.test(next) && Number(next) <= MAX_FRET) this.#edit(next);
1721
+ if (/^(0|[1-9][0-9]?)$/.test(next) && Number(next) <= this.maxFret) this.#edit(next);
1674
1722
  }
1675
1723
 
1676
1724
  #parenthesize() {
@@ -1751,10 +1799,10 @@ class AsciiTabs extends Base {
1751
1799
  return this.#moveHorizontal(-1);
1752
1800
  case 'ArrowUp':
1753
1801
  e.preventDefault();
1754
- return this.#moveVertical(-1);
1802
+ return e.shiftKey ? this.#refinger(-1) : this.#moveVertical(-1);
1755
1803
  case 'ArrowDown':
1756
1804
  e.preventDefault();
1757
- return this.#moveVertical(1);
1805
+ return e.shiftKey ? this.#refinger(1) : this.#moveVertical(1);
1758
1806
  case 'Backspace': {
1759
1807
  e.preventDefault();
1760
1808
  const { t, c, s } = this.#cur;
@@ -1786,7 +1834,7 @@ class AsciiTabs extends Base {
1786
1834
  const last = Math.max(...this.#selection.map(p => p.c));
1787
1835
  const columns = Array.from({ length: last - first + 1 }, emptyColumn);
1788
1836
  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 });
1837
+ return format(columns, { spacing: this.#spacing, labels: this.labels, stringBars: this.stringBars, notation: this.notation, tuning: this.tuning });
1790
1838
  }
1791
1839
 
1792
1840
  #onCopy = e => {
@@ -1975,7 +2023,7 @@ class AsciiTabs extends Base {
1975
2023
  async copy(index = 0) {
1976
2024
  const tab = this.#tabs[index];
1977
2025
  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() }));
2026
+ 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
2027
  const button = this.#sheetEls[index]?.querySelector('.ascii-tabs-copy');
1980
2028
  if (!button) return;
1981
2029
  this.#flashCopied(button);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ascii-tabs",
3
- "version": "0.10.3",
3
+ "version": "0.11.2",
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",