text-guitar-chart 0.0.1

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.
@@ -0,0 +1,1050 @@
1
+ //@ts-check
2
+
3
+ import fingeringToString from './fingeringToString.js';
4
+ import { SVGuitarChord } from 'svguitar';
5
+
6
+ /**
7
+ * Available colors for dots: red and black only
8
+ */
9
+ export const DOT_COLORS = {
10
+ RED: '#e74c3c',
11
+ BLACK: '#000000'
12
+ };
13
+
14
+ /**
15
+ * EditableSVGuitarChord - Wrapper around SVGuitarChord that adds interactive editing capabilities
16
+ *
17
+ * Features:
18
+ * - Click on fretboard to add dots
19
+ * - Click existing dots to edit/remove them
20
+ * - Dialog for editing dot text and color
21
+ * - Fret count selector
22
+ * - Maintains same interface as SVGuitarChord
23
+ */
24
+ export class EditableSVGuitarChord {
25
+ /**
26
+ * @param {HTMLElement} container
27
+ * @param {any} SVGuitarChordClass
28
+ */
29
+ constructor(container, SVGuitarChordClass = SVGuitarChord) {
30
+ this.container = container;
31
+ this.SVGuitarChordClass = SVGuitarChordClass;
32
+
33
+ /** @type {import("svguitar").Chord} */
34
+ this.chordConfig = { fingers: [], barres: [], title: undefined, position: undefined };
35
+
36
+ /** @type {any} */
37
+ this.config = { frets: 5, noPosition: true };
38
+
39
+ this.svgChord = null;
40
+ this.isDialogOpen = false;
41
+ this.controlsCreated = false;
42
+ this.currentEditElement = null;
43
+
44
+ /** @type {Function|null} */
45
+ this.changeCallback = null;
46
+
47
+ // Only create controls if we have a real DOM environment
48
+ if (typeof document !== 'undefined') {
49
+ this.createControls();
50
+ }
51
+ // Add the CSS rules if not already added
52
+ this.addCustomCSS();
53
+ }
54
+
55
+ /**
56
+ * Create controls and containers
57
+ */
58
+ createControls() {
59
+ this.controlsCreated = true;
60
+
61
+ // Create wrapper with flex layout
62
+ this.wrapper = document.createElement('div');
63
+ this.wrapper.className = 'editable-svguitar-wrapper';
64
+ this.wrapper.style.cssText = 'position: relative;';
65
+ this.container.appendChild(this.wrapper);
66
+
67
+ // Create settings button
68
+ this.settingsButton = document.createElement('button');
69
+ this.settingsButton.className = 'editable-svguitar-settings-btn';
70
+ this.settingsButton.innerHTML = '⚙️';
71
+ this.settingsButton.title = 'Edit title and position';
72
+ this.settingsButton.style.cssText = `
73
+ position: absolute;
74
+ top: 5px;
75
+ left: 5px;
76
+ background: white;
77
+ border: 1px solid #333;
78
+ border-radius: 4px;
79
+ padding: 4px 8px;
80
+ cursor: pointer;
81
+ font-size: 14px;
82
+ z-index: 10;
83
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
84
+ `;
85
+ this.settingsButton.addEventListener('click', () => this.openSettingsDialog());
86
+ this.wrapper.appendChild(this.settingsButton);
87
+
88
+ // Create SVG container
89
+ this.svgContainer = document.createElement('div');
90
+ this.svgContainer.className = 'editable-svguitar-svg';
91
+ this.wrapper.appendChild(this.svgContainer);
92
+
93
+ // Create dialogs
94
+ this.createDialog();
95
+ this.createSettingsDialog();
96
+ }
97
+
98
+ /**
99
+ * Create the settings dialog for title and position
100
+ */
101
+ createSettingsDialog() {
102
+ this.settingsDialog = document.createElement('div');
103
+ this.settingsDialog.className = 'editable-svguitar-settings-dialog';
104
+ this.settingsDialog.style.cssText = `
105
+ display: none;
106
+ position: absolute;
107
+ background: white;
108
+ border: 2px solid #333;
109
+ border-radius: 8px;
110
+ padding: 20px;
111
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
112
+ z-index: 1000;
113
+ min-width: 280px;
114
+ `;
115
+
116
+ const title = document.createElement('h3');
117
+ title.textContent = 'Chord Settings';
118
+ title.style.cssText = 'margin: 0 0 15px 0; font-size: 16px;';
119
+
120
+ // Title input
121
+ const titleSection = document.createElement('div');
122
+ titleSection.style.cssText = 'margin-bottom: 15px;';
123
+
124
+ const titleLabel = document.createElement('label');
125
+ titleLabel.textContent = 'Title (optional): ';
126
+ titleLabel.style.cssText = 'display: block; margin-bottom: 5px; font-weight: bold;';
127
+
128
+ this.titleInput = document.createElement('input');
129
+ this.titleInput.type = 'text';
130
+ this.titleInput.placeholder = 'e.g. A min';
131
+ this.titleInput.maxLength = 10;
132
+ this.titleInput.style.cssText = 'width: 10em; padding: 6px; border: 1px solid #ccc; border-radius: 3px; box-sizing: border-box;';
133
+
134
+ titleLabel.appendChild(this.titleInput);
135
+ titleSection.appendChild(titleLabel);
136
+
137
+ // Position input
138
+ const positionSection = document.createElement('div');
139
+ positionSection.style.cssText = 'margin-bottom: 15px;';
140
+
141
+ const positionLabel = document.createElement('label');
142
+ positionLabel.textContent = 'Position (optional): ';
143
+ positionLabel.style.cssText = 'display: block; margin-bottom: 5px; font-weight: bold;';
144
+
145
+ this.positionInput = document.createElement('input');
146
+ this.positionInput.type = 'number';
147
+ this.positionInput.min = '1';
148
+ this.positionInput.max = '30';
149
+ this.positionInput.placeholder = '1-30';
150
+ this.positionInput.style.cssText = 'width: 5em; padding: 6px; border: 1px solid #ccc; border-radius: 3px; box-sizing: border-box;';
151
+
152
+ positionLabel.appendChild(this.positionInput);
153
+ positionSection.appendChild(positionLabel);
154
+
155
+ // Buttons
156
+ const buttonDiv = document.createElement('div');
157
+ buttonDiv.style.cssText = 'display: flex; gap: 10px; justify-content: flex-end;';
158
+
159
+ const cancelBtn = document.createElement('button');
160
+ cancelBtn.textContent = 'Cancel';
161
+ cancelBtn.style.cssText = 'padding: 6px 12px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer;';
162
+ cancelBtn.addEventListener('click', () => this.closeSettingsDialog());
163
+
164
+ const saveBtn = document.createElement('button');
165
+ saveBtn.textContent = 'Save';
166
+ saveBtn.style.cssText = 'padding: 6px 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;';
167
+ saveBtn.addEventListener('click', () => this.saveSettings());
168
+
169
+ buttonDiv.appendChild(cancelBtn);
170
+ buttonDiv.appendChild(saveBtn);
171
+
172
+ this.settingsDialog.appendChild(title);
173
+ this.settingsDialog.appendChild(titleSection);
174
+ this.settingsDialog.appendChild(positionSection);
175
+ this.settingsDialog.appendChild(buttonDiv);
176
+
177
+ document.body.appendChild(this.settingsDialog);
178
+
179
+ // Add backdrop for settings dialog
180
+ this.settingsBackdrop = document.createElement('div');
181
+ this.settingsBackdrop.className = 'editable-svguitar-settings-backdrop';
182
+ this.settingsBackdrop.style.cssText = `
183
+ display: none;
184
+ position: fixed;
185
+ top: 0;
186
+ left: 0;
187
+ width: 100%;
188
+ height: 100%;
189
+ background: rgba(0,0,0,0.5);
190
+ z-index: 999;
191
+ `;
192
+ this.settingsBackdrop.addEventListener('click', () => this.closeSettingsDialog());
193
+ document.body.appendChild(this.settingsBackdrop);
194
+ }
195
+
196
+ /**
197
+ * Create the edit dialog
198
+ */
199
+ createDialog() {
200
+ this.dialog = document.createElement('div');
201
+ this.dialog.className = 'editable-svguitar-dialog';
202
+ this.dialog.style.cssText = `
203
+ display: none;
204
+ position: absolute;
205
+ background: white;
206
+ border: 2px solid #333;
207
+ border-radius: 8px;
208
+ padding: 20px;
209
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
210
+ z-index: 1000;
211
+ min-width: 250px;
212
+ `;
213
+
214
+ const title = document.createElement('h3');
215
+ title.textContent = 'Edit Dot';
216
+ title.style.cssText = 'margin: 0 0 15px 0; font-size: 16px;';
217
+
218
+ // Color selection with radio buttons
219
+ const colorSection = document.createElement('div');
220
+ colorSection.style.cssText = 'margin-bottom: 15px;';
221
+
222
+ const colorLabel = document.createElement('div');
223
+ colorLabel.textContent = 'Color:';
224
+ colorLabel.style.cssText = 'font-weight: bold; margin-bottom: 8px;';
225
+ colorSection.appendChild(colorLabel);
226
+
227
+ const colorOptions = document.createElement('div');
228
+ colorOptions.style.cssText = 'display: flex; gap: 15px;';
229
+
230
+ // Red option
231
+ const redOption = document.createElement('label');
232
+ redOption.style.cssText = 'display: flex; align-items: center; cursor: pointer;';
233
+
234
+ this.redRadio = document.createElement('input');
235
+ this.redRadio.type = 'radio';
236
+ this.redRadio.name = 'dotColor';
237
+ this.redRadio.value = DOT_COLORS.RED;
238
+ this.redRadio.addEventListener('change', () => this.updateDotColor());
239
+
240
+ const redLabel = document.createElement('span');
241
+ redLabel.textContent = 'Red';
242
+ redLabel.style.cssText = 'margin-left: 5px; color: #e74c3c; font-weight: bold;';
243
+
244
+ redOption.appendChild(this.redRadio);
245
+ redOption.appendChild(redLabel);
246
+
247
+ // Black option
248
+ const blackOption = document.createElement('label');
249
+ blackOption.style.cssText = 'display: flex; align-items: center; cursor: pointer;';
250
+
251
+ this.blackRadio = document.createElement('input');
252
+ this.blackRadio.type = 'radio';
253
+ this.blackRadio.name = 'dotColor';
254
+ this.blackRadio.value = DOT_COLORS.BLACK;
255
+ this.blackRadio.checked = true; // Default to black
256
+ this.blackRadio.addEventListener('change', () => this.updateDotColor());
257
+
258
+ const blackLabel = document.createElement('span');
259
+ blackLabel.textContent = 'Black';
260
+ blackLabel.style.cssText = 'margin-left: 5px; color: #000000; font-weight: bold;';
261
+
262
+ blackOption.appendChild(this.blackRadio);
263
+ blackOption.appendChild(blackLabel);
264
+
265
+ colorOptions.appendChild(redOption);
266
+ colorOptions.appendChild(blackOption);
267
+ colorSection.appendChild(colorOptions);
268
+
269
+ // Text input (conditional on black color)
270
+ this.textSection = document.createElement('div');
271
+ this.textSection.style.cssText = 'margin-bottom: 15px;';
272
+
273
+ const textLabel = document.createElement('label');
274
+ textLabel.textContent = 'Text (optional): ';
275
+ textLabel.style.cssText = 'display: block; margin-bottom: 5px; font-weight: bold;';
276
+
277
+ this.textInput = document.createElement('input');
278
+ this.textInput.type = 'text';
279
+ this.textInput.maxLength = 2; // Reduced from 3 to 2
280
+ this.textInput.placeholder = '1-2 chars';
281
+ this.textInput.style.cssText = 'width: 60px; padding: 4px; border: 1px solid #ccc; border-radius: 3px;';
282
+
283
+ // Add real-time text change listener
284
+ this.textInput.addEventListener('input', () => this.updateDotText());
285
+
286
+ textLabel.appendChild(this.textInput);
287
+ this.textSection.appendChild(textLabel);
288
+
289
+ const buttonDiv = document.createElement('div');
290
+ buttonDiv.style.cssText = 'display: flex; gap: 10px; justify-content: flex-end;';
291
+
292
+ const removeBtn = document.createElement('button');
293
+ removeBtn.textContent = 'Remove';
294
+ removeBtn.style.cssText = 'padding: 6px 12px; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer;';
295
+ removeBtn.addEventListener('click', () => this.removeDot());
296
+
297
+ const doneBtn = document.createElement('button');
298
+ doneBtn.textContent = 'Done';
299
+ doneBtn.style.cssText = 'padding: 6px 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;';
300
+ doneBtn.addEventListener('click', () => this.closeDialog());
301
+
302
+ buttonDiv.appendChild(removeBtn);
303
+ buttonDiv.appendChild(doneBtn);
304
+
305
+ this.dialog.appendChild(title);
306
+ this.dialog.appendChild(colorSection);
307
+ this.dialog.appendChild(this.textSection);
308
+ this.dialog.appendChild(buttonDiv);
309
+
310
+ document.body.appendChild(this.dialog);
311
+
312
+ // Add backdrop
313
+ this.backdrop = document.createElement('div');
314
+ this.backdrop.className = 'editable-svguitar-backdrop';
315
+ this.backdrop.style.cssText = `
316
+ display: none;
317
+ position: fixed;
318
+ top: 0;
319
+ left: 0;
320
+ width: 100%;
321
+ height: 100%;
322
+ background: rgba(0,0,0,0.5);
323
+ z-index: 999;
324
+ `;
325
+ this.backdrop.addEventListener('click', () => this.closeDialog());
326
+ document.body.appendChild(this.backdrop);
327
+ }
328
+
329
+ /**
330
+ * Set chord configuration
331
+ * @param {import("svguitar").Chord} config
332
+ * @returns {EditableSVGuitarChord}
333
+ */
334
+ chord(config) {
335
+ this.chordConfig = {
336
+ fingers: config.fingers || [],
337
+ barres: config.barres || [],
338
+ title: config.title || '',
339
+ position: config.position
340
+ };
341
+ // Update noPosition config based on whether position is set
342
+ this.config.noPosition = config.position === undefined;
343
+ return this;
344
+ }
345
+
346
+ /**
347
+ * Configure SVGuitar options
348
+ * @param {any} config
349
+ * @returns {EditableSVGuitarChord}
350
+ */
351
+ configure(config) {
352
+ this.config = { ...this.config, ...config };
353
+ return this;
354
+ }
355
+
356
+ /**
357
+ * Calculate dynamic fret count based on chord content
358
+ * @returns {number} - Number of frets needed (minimum 3, max dot position + 1)
359
+ */
360
+ calculateDynamicFrets() {
361
+ const { fingers } = this.chordConfig;
362
+
363
+ // Find the highest fret position
364
+ let maxFret = 0;
365
+ for (const [, fret] of fingers) {
366
+ if(typeof fret === 'string') continue; // skip 'x' positions
367
+ if (fret > maxFret) {
368
+ maxFret = fret;
369
+ }
370
+ }
371
+
372
+ // Return minimum 3 frets, or highest fret + 1 for one empty fret above
373
+ return Math.max(3, maxFret);
374
+ }
375
+
376
+ /**
377
+ * Draw the chord with interactive capabilities
378
+ * @param {number | undefined} [frets] - Force redraw even if already drawn
379
+ * @returns {EditableSVGuitarChord}
380
+ */
381
+ draw(frets) {
382
+ // Ensure controls are created if we have a DOM environment
383
+ if (typeof document !== 'undefined' && !this.controlsCreated) {
384
+ this.createControls();
385
+ }
386
+
387
+ // Update fret count dynamically
388
+ this.config.frets = Math.max(frets ?? 0, this.calculateDynamicFrets());
389
+
390
+ // Add transparent placeholder dots for all fret positions
391
+ const chordWithPlaceholders = this.addPlaceholderDots(this.chordConfig);
392
+
393
+ // Create new SVGuitar instance only if we have an svgContainer
394
+ if (this.svgContainer) {
395
+ this.svgChord = new this.SVGuitarChordClass(this.svgContainer);
396
+ this.svgChord.chord(chordWithPlaceholders).configure(this.config).draw();
397
+
398
+ // Add event listeners after drawing
399
+ this.addEventListeners();
400
+ }
401
+
402
+ return this;
403
+ }
404
+
405
+ /**
406
+ * Redraw the chord
407
+ * @param {number | undefined} [frets] - Force redraw even if already drawn
408
+ */
409
+ redraw(frets) {
410
+ if (this.svgContainer) {
411
+ this.svgContainer.innerHTML = '';
412
+ }
413
+ this.draw(frets);
414
+ }
415
+
416
+ /**
417
+ * Add transparent placeholder dots for empty positions
418
+ * @param {import("svguitar").Chord} config
419
+ * @returns {import("svguitar").Chord}
420
+ */
421
+ addPlaceholderDots(config) {
422
+ const { fingers, title, position } = config;
423
+ const placeholders = [];
424
+
425
+ // Add placeholders for all string/fret combinations (they are both 1-based)
426
+ for (let string = 1; string <= 6; string++) {
427
+ for (let fret = 1; fret <= this.config.frets; fret++) {
428
+ // Skip if there's already a finger at this position
429
+ const exists = fingers.some(([s, f]) => s === string && f === fret);
430
+ if (!exists) {
431
+ /** @type {import("svguitar").Finger} */
432
+ const placeholder = [string, fret, {
433
+ color: 'transparent',
434
+ className: 'placeholder-dot',
435
+ text: ''
436
+ }];
437
+ placeholders.push(placeholder);
438
+ }
439
+ }
440
+ }
441
+
442
+ // Add placeholders for fret 0 (open strings) and handle CSS visibility
443
+ for (let string = 1; string <= 6; string++) {
444
+ const openString = fingers.some(([s, f]) => s === string && f === 0);
445
+
446
+ if (!openString) {
447
+ /** @type {import("svguitar").Finger} */
448
+ const placeholder = [string, 0];
449
+ placeholders.push(placeholder);
450
+ }
451
+
452
+ if (!this.svgContainer) continue;
453
+
454
+ // Add placeholder if no open string or muted string exists
455
+ if (openString) {
456
+ this.svgContainer.classList.remove(`hide-open-string-${6 - string}`);
457
+ } else {
458
+ this.svgContainer.classList.add(`hide-open-string-${6 - string}`);
459
+ }
460
+ }
461
+
462
+ // Build result with title and position included if they have values
463
+ const result = {
464
+ fingers: [...fingers, ...placeholders],
465
+ barres: config.barres
466
+ };
467
+
468
+ // Only include title if it's not empty
469
+ if (title && title.trim()) {
470
+ result.title = title;
471
+ }
472
+
473
+ // Only include position if it's defined
474
+ if (position !== undefined) {
475
+ result.position = position;
476
+ }
477
+
478
+ return result;
479
+ }
480
+
481
+ /**
482
+ * Add event listeners to SVG elements
483
+ */
484
+ addEventListeners() {
485
+ const svg = this.svgContainer.querySelector('svg');
486
+ if (!svg) return;
487
+
488
+ // Use event delegation on the SVG
489
+ svg.addEventListener('click', (event) => {
490
+ const target = /** @type {Element} */ (event.target);
491
+
492
+ // Check if clicked on an open string element
493
+ if (target.classList.contains('open-string')) {
494
+ this.handleOpenStringClick(target);
495
+ }
496
+ // Check if clicked on a finger circle
497
+ else if (target.tagName === 'circle' && target.classList.contains('finger-circle')) {
498
+ this.handleDotClick(target);
499
+ } else if (target.tagName === 'text' && target.previousElementSibling && target.previousElementSibling.tagName === 'circle' && target.previousElementSibling.classList.contains('finger-circle')) {
500
+ this.handleDotClick(target.previousElementSibling);
501
+ }
502
+
503
+ });
504
+
505
+
506
+ const resizeOnHover = (size) => {
507
+ this.redraw(size);
508
+ }
509
+ let hoverResizeTimeout = null;
510
+
511
+ const overHandler = (event) => {
512
+ const target = /** @type {Element} */ (event.target);
513
+
514
+ if (target.tagName === 'circle' && target.classList.contains('finger-circle')) {
515
+ const classes = Array.from(target.classList);
516
+ const fretClass = classes.find(c => c.startsWith('finger-fret-'));
517
+
518
+ if (fretClass) {
519
+ const fretNumber = fretClass.replace('finger-fret-', '');
520
+ // console.log(`Still hovering over fret: ${fretNumber}`);
521
+ clearTimeout(hoverResizeTimeout);
522
+ hoverResizeTimeout = setTimeout(resizeOnHover, 1000, parseInt(fretNumber, 10) + 2);
523
+ }
524
+ }
525
+ }
526
+ svg.addEventListener('mouseover', overHandler);
527
+ svg.addEventListener('mouseout', (event) => {
528
+ const target = /** @type {Element} */ (event.target);
529
+ if (target.tagName === 'circle' && target.classList.contains('finger-circle')) {
530
+ clearTimeout(hoverResizeTimeout);
531
+ hoverResizeTimeout = setTimeout(resizeOnHover, 1000, undefined);
532
+ }
533
+ });
534
+ }
535
+
536
+
537
+ /**
538
+ * Handle click on a dot (finger circle)
539
+ * @param {Element} circleElement
540
+ */
541
+ handleDotClick(circleElement) {
542
+ if (this.isDialogOpen) return;
543
+
544
+ // Store the clicked element for positioning
545
+ this.currentEditElement = circleElement;
546
+
547
+ // Extract string and fret from classes
548
+ const classes = Array.from(circleElement.classList);
549
+ const stringClass = classes.find(c => c.startsWith('finger-string-'));
550
+ const fretClass = classes.find(c => c.startsWith('finger-fret-'));
551
+
552
+ if (!stringClass || !fretClass) return;
553
+
554
+ // Convert to 1-based string and fret numbers
555
+ // also invert string number (1=high E, 6=low E)
556
+ const string = 6 - parseInt(stringClass.replace('finger-string-', ''), 10);
557
+ const fret = 1 + parseInt(fretClass.replace('finger-fret-', ''), 10);
558
+
559
+ // Check if this is a placeholder (transparent) or existing dot
560
+ const isPlaceholder = circleElement.getAttribute('fill') === 'transparent';
561
+
562
+ if (isPlaceholder) {
563
+ // Add new dot
564
+ this.addDot(string, fret);
565
+ } else {
566
+ // Edit existing dot
567
+ this.editDot(string, fret);
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Handle click on an open string element
573
+ * @param {Element} openStringElement
574
+ */
575
+ handleOpenStringClick(openStringElement) {
576
+ if (this.isDialogOpen) return;
577
+
578
+ // Extract string number from classes
579
+ const classes = Array.from(openStringElement.classList);
580
+ const stringClass = classes.find(c => c.startsWith('open-string-'));
581
+
582
+ if (!stringClass) return;
583
+
584
+ // Convert to 1-based string number (class is 0-based, inverted)
585
+ const stringIndex = parseInt(stringClass.replace('open-string-', ''), 10);
586
+ const string = 6 - stringIndex;
587
+
588
+ // Check current state of this string
589
+ const existingFingerIndex = this.chordConfig.fingers.findIndex(([s, f]) => s === string && (f === 0 || f === 'x'));
590
+
591
+ if (existingFingerIndex === -1) {
592
+ // No fingering exists, add fret 0 (open string)
593
+ this.chordConfig.fingers.push([string, 0]);
594
+ } else {
595
+ const existingFinger = this.chordConfig.fingers[existingFingerIndex];
596
+ const existingFret = existingFinger[1];
597
+
598
+ if (existingFret === 0) {
599
+ // Change from open (0) to muted ('x')
600
+ this.chordConfig.fingers[existingFingerIndex] = [string, 'x'];
601
+ } else if (existingFret === 'x') {
602
+ // Remove muted fingering
603
+ this.chordConfig.fingers.splice(existingFingerIndex, 1);
604
+ }
605
+ }
606
+
607
+ this.redraw();
608
+ this.triggerChange();
609
+ }
610
+
611
+ /**
612
+ * Add a new dot at the specified position
613
+ * @param {number} string
614
+ * @param {number} fret
615
+ */
616
+ addDot(string, fret) {
617
+ // Add to fingers array (default to black to allow text)
618
+ this.chordConfig.fingers.push([string, fret, { text: '', color: DOT_COLORS.BLACK }]);
619
+ this.redraw();
620
+ this.triggerChange();
621
+ }
622
+
623
+ /**
624
+ * Edit an existing dot
625
+ * @param {number} string
626
+ * @param {number} fret
627
+ */
628
+ editDot(string, fret) {
629
+ // Find the finger
630
+ const finger = this.chordConfig.fingers.find(([s, f]) => s === string && f === fret);
631
+ if (!finger) return;
632
+
633
+ this.currentEditFinger = finger;
634
+ this.currentEditString = string;
635
+ this.currentEditFret = fret;
636
+
637
+
638
+ // Populate dialog
639
+ const currentColor = typeof finger[2] === 'object' && finger[2]?.color || DOT_COLORS.BLACK;
640
+ const currentText = typeof finger[2] === 'object' && finger[2]?.text || '';
641
+
642
+ // Normalize color to red or black (handle legacy colors)
643
+ const normalizedColor = currentColor === DOT_COLORS.RED ? DOT_COLORS.RED : DOT_COLORS.BLACK;
644
+
645
+ // Set radio buttons
646
+ this.redRadio.checked = normalizedColor === DOT_COLORS.RED;
647
+ this.blackRadio.checked = normalizedColor === DOT_COLORS.BLACK;
648
+
649
+ // Set text and update visibility
650
+ this.textInput.value = currentText;
651
+ this.updateTextSectionVisibility();
652
+
653
+ this.openDialog();
654
+ }
655
+
656
+ /**
657
+ * Open the edit dialog
658
+ */
659
+ openDialog() {
660
+ this.isDialogOpen = true;
661
+ this.dialog.style.display = 'block';
662
+ this.backdrop.style.display = 'block';
663
+
664
+ // Position dialog relative to the clicked element
665
+ if (this.currentEditElement) {
666
+ this.positionDialog();
667
+ }
668
+
669
+ // Update text section visibility
670
+ this.updateTextSectionVisibility();
671
+
672
+ // Focus appropriate element
673
+ if (this.blackRadio.checked && !this.textInput.disabled) {
674
+ this.textInput.focus();
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Position dialog relative to the clicked element
680
+ */
681
+ positionDialog() {
682
+ if (!this.currentEditElement || !this.dialog) return;
683
+
684
+ // Get the bounding rect of the clicked element
685
+ const elementRect = this.currentEditElement.getBoundingClientRect();
686
+ const dialogRect = this.dialog.getBoundingClientRect();
687
+
688
+ // Calculate position
689
+ const elementCenterX = elementRect.left + elementRect.width / 2;
690
+ const elementCenterY = elementRect.top + elementRect.height / 2;
691
+
692
+ // Position dialog to the right and slightly above the dot
693
+ let dialogX = elementCenterX + 20;
694
+ let dialogY = elementCenterY - dialogRect.height / 2;
695
+
696
+ // Ensure dialog stays within viewport bounds
697
+ const padding = 10;
698
+ const maxX = window.innerWidth - dialogRect.width - padding;
699
+ const maxY = window.innerHeight - dialogRect.height - padding;
700
+
701
+ let arrowSide = 'left'; // Default: arrow points right (dot is to the left of dialog)
702
+
703
+ if (dialogX > maxX) {
704
+ // Position to the left of the dot instead
705
+ dialogX = elementCenterX - dialogRect.width - 20;
706
+ arrowSide = 'right'; // Arrow points left (dot is to the right of dialog)
707
+ }
708
+ if (dialogX < padding) dialogX = padding;
709
+ if (dialogY < padding) dialogY = padding;
710
+ if (dialogY > maxY) dialogY = maxY;
711
+
712
+ // Apply positioning
713
+ this.dialog.style.left = `${dialogX}px`;
714
+ this.dialog.style.top = `${dialogY}px`;
715
+
716
+ // Add arrow CSS class and calculate arrow position
717
+ this.addArrowCSS(arrowSide, elementCenterY, dialogY, dialogRect.height);
718
+ }
719
+
720
+ /**
721
+ * Add CSS arrow using ::after pseudo-element
722
+ * @param {string} side - 'left' or 'right' indicating arrow direction
723
+ * @param {number} dotY - Y position of the clicked dot
724
+ * @param {number} dialogY - Y position of the dialog
725
+ * @param {number} dialogHeight - Height of the dialog
726
+ */
727
+ addArrowCSS(side, dotY, dialogY, dialogHeight) {
728
+ // Remove any existing arrow classes
729
+ this.dialog.classList.remove('arrow-left', 'arrow-right');
730
+
731
+ // Calculate arrow vertical position relative to dialog
732
+ const arrowY = Math.max(20, Math.min(dialogHeight - 20, dotY - dialogY));
733
+
734
+ // Add appropriate arrow class and set CSS custom property for position
735
+ this.dialog.classList.add(`arrow-${side}`);
736
+ this.dialog.style.setProperty('--arrow-y', `${arrowY}px`);
737
+ }
738
+
739
+ /**
740
+ * Ensure arrow CSS rules are added to the document
741
+ */
742
+ addCustomCSS() {
743
+ if (typeof document === 'undefined') return;
744
+ if (document.getElementById('editable-svguitar-arrow-styles')) return;
745
+
746
+ const style = document.createElement('style');
747
+ style.id = 'editable-svguitar-custom-CSS';
748
+ style.textContent = `
749
+ .editable-svguitar-dialog.arrow-left::after {
750
+ content: '';
751
+ position: absolute;
752
+ left: -16px;
753
+ top: var(--arrow-y, 50px);
754
+ width: 0;
755
+ height: 0;
756
+ border: 8px solid transparent;
757
+ border-right-color: white;
758
+ transform: translateY(-50%);
759
+ }
760
+
761
+ .editable-svguitar-dialog.arrow-right::after {
762
+ content: '';
763
+ position: absolute;
764
+ right: -16px;
765
+ top: var(--arrow-y, 50px);
766
+ width: 0;
767
+ height: 0;
768
+ border: 8px solid transparent;
769
+ border-left-color: white;
770
+ transform: translateY(-50%);
771
+ }
772
+
773
+ .editable-svguitar-svg .open-string{
774
+ fill: transparent !important;
775
+ }
776
+
777
+ .editable-svguitar-svg.hide-open-string-0 .open-string-0,
778
+ .editable-svguitar-svg.hide-open-string-1 .open-string-1,
779
+ .editable-svguitar-svg.hide-open-string-2 .open-string-2,
780
+ .editable-svguitar-svg.hide-open-string-3 .open-string-3,
781
+ .editable-svguitar-svg.hide-open-string-4 .open-string-4,
782
+ .editable-svguitar-svg.hide-open-string-5 .open-string-5 {
783
+ stroke: transparent !important;
784
+ fill: transparent !important;
785
+ }
786
+
787
+ .editable-svguitar-settings-btn:hover {
788
+ background: #f0f0f0;
789
+ }
790
+ `;
791
+ document.head.appendChild(style);
792
+ }
793
+
794
+ /**
795
+ * Close the edit dialog
796
+ */
797
+ closeDialog() {
798
+ this.isDialogOpen = false;
799
+ this.dialog.style.display = 'none';
800
+ this.backdrop.style.display = 'none';
801
+
802
+ // Remove arrow CSS classes
803
+ this.dialog.classList.remove('arrow-left', 'arrow-right');
804
+ this.dialog.style.removeProperty('--arrow-y');
805
+
806
+ this.currentEditFinger = null;
807
+ this.currentEditElement = null;
808
+ }
809
+
810
+ /**
811
+ * Update text section visibility based on color selection
812
+ */
813
+ updateTextSectionVisibility() {
814
+ if (!this.textSection) return;
815
+
816
+ const isBlack = this.blackRadio && this.blackRadio.checked;
817
+ this.textSection.style.display = isBlack ? 'block' : 'none';
818
+
819
+ // Disable text input for red dots
820
+ if (this.textInput) {
821
+ this.textInput.disabled = !isBlack;
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Update dot text in real-time
827
+ */
828
+ updateDotText() {
829
+ if (!this.currentEditFinger) return;
830
+
831
+ // Update the finger options
832
+ if (!this.currentEditFinger[2]) {
833
+ this.currentEditFinger[2] = {};
834
+ }
835
+
836
+ const fingerOptions = typeof this.currentEditFinger[2] === 'object' ? this.currentEditFinger[2] : {};
837
+ this.currentEditFinger[2] = { ...fingerOptions, text: this.textInput.value };
838
+
839
+ this.redraw();
840
+ this.triggerChange();
841
+ }
842
+
843
+ /**
844
+ * Update dot color in real-time
845
+ */
846
+ updateDotColor() {
847
+ if (!this.currentEditFinger) return;
848
+
849
+ // Update the finger options
850
+ if (!this.currentEditFinger[2]) {
851
+ this.currentEditFinger[2] = {};
852
+ }
853
+
854
+ // Get selected color from radio buttons
855
+ const selectedColor = this.redRadio.checked ? DOT_COLORS.RED : DOT_COLORS.BLACK;
856
+
857
+ const fingerOptions = typeof this.currentEditFinger[2] === 'object' ? this.currentEditFinger[2] : {};
858
+ this.currentEditFinger[2] = { ...fingerOptions, color: selectedColor };
859
+
860
+ // Clear text if red is selected
861
+ if (selectedColor === DOT_COLORS.RED) {
862
+ this.currentEditFinger[2].text = '';
863
+ this.textInput.value = '';
864
+ }
865
+
866
+ this.updateTextSectionVisibility();
867
+ this.redraw();
868
+ this.triggerChange();
869
+ }
870
+
871
+ /**
872
+ * Save changes to the current dot
873
+ */
874
+ saveDot() {
875
+ if (!this.currentEditFinger) return;
876
+
877
+ // Update the finger options
878
+ if (!this.currentEditFinger[2]) {
879
+ this.currentEditFinger[2] = {};
880
+ }
881
+
882
+ // Get selected color from radio buttons
883
+ const selectedColor = this.redRadio.checked ? DOT_COLORS.RED : DOT_COLORS.BLACK;
884
+ this.currentEditFinger[2] = { text: this.textInput.value, color: selectedColor };
885
+
886
+ this.closeDialog();
887
+ this.redraw();
888
+ }
889
+
890
+ /**
891
+ * Remove the current dot
892
+ */
893
+ removeDot() {
894
+ if (!this.currentEditFinger) return;
895
+
896
+ // Remove from fingers array
897
+ const index = this.chordConfig.fingers.findIndex(
898
+ ([s, f]) => s === this.currentEditString && f === this.currentEditFret
899
+ );
900
+
901
+ if (index >= 0) {
902
+ this.chordConfig.fingers.splice(index, 1);
903
+ }
904
+
905
+ this.closeDialog();
906
+ this.redraw();
907
+ this.triggerChange();
908
+ }
909
+
910
+ /**
911
+ * Open the settings dialog
912
+ */
913
+ openSettingsDialog() {
914
+ // Populate current values
915
+ this.titleInput.value = this.chordConfig.title || '';
916
+ this.positionInput.value = this.chordConfig.position !== undefined ? String(this.chordConfig.position) : '';
917
+
918
+ // Show dialog
919
+ this.settingsDialog.style.display = 'block';
920
+ this.settingsBackdrop.style.display = 'block';
921
+
922
+ // Position dialog near the settings button
923
+ this.positionSettingsDialog();
924
+
925
+ // Focus title input
926
+ this.titleInput.focus();
927
+ }
928
+
929
+ /**
930
+ * Position settings dialog near the settings button
931
+ */
932
+ positionSettingsDialog() {
933
+ if (!this.settingsButton || !this.settingsDialog) return;
934
+
935
+ // Get the bounding rect of the settings button
936
+ const buttonRect = this.settingsButton.getBoundingClientRect();
937
+ const dialogRect = this.settingsDialog.getBoundingClientRect();
938
+
939
+ // Position dialog below and to the right of the button
940
+ let dialogX = buttonRect.left;
941
+ let dialogY = buttonRect.bottom + 5;
942
+
943
+ // Ensure dialog stays within viewport bounds
944
+ const padding = 10;
945
+ const maxX = window.innerWidth - dialogRect.width - padding;
946
+ const maxY = window.innerHeight - dialogRect.height - padding;
947
+
948
+ if (dialogX > maxX) dialogX = maxX;
949
+ if (dialogX < padding) dialogX = padding;
950
+ if (dialogY > maxY) dialogY = buttonRect.top - dialogRect.height - 5;
951
+ if (dialogY < padding) dialogY = padding;
952
+
953
+ // Apply positioning
954
+ this.settingsDialog.style.left = `${dialogX}px`;
955
+ this.settingsDialog.style.top = `${dialogY}px`;
956
+ }
957
+
958
+ /**
959
+ * Close the settings dialog
960
+ */
961
+ closeSettingsDialog() {
962
+ if (this.settingsDialog) {
963
+ this.settingsDialog.style.display = 'none';
964
+ }
965
+ if (this.settingsBackdrop) {
966
+ this.settingsBackdrop.style.display = 'none';
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Save settings from the dialog
972
+ */
973
+ saveSettings() {
974
+ // Get and validate values
975
+ const title = this.titleInput.value.trim();
976
+ const positionStr = this.positionInput.value.trim();
977
+
978
+ // Update title (can be empty)
979
+ this.chordConfig.title = title;
980
+
981
+ // Update position with validation
982
+ if (positionStr === '') {
983
+ this.chordConfig.position = undefined;
984
+ } else {
985
+ const position = parseInt(positionStr, 10);
986
+ if (isNaN(position) || position < 0 || position > 30) {
987
+ alert('Position must be a number between 0 and 30');
988
+ return;
989
+ }
990
+ this.chordConfig.position = position;
991
+ }
992
+
993
+ // Toggle noPosition based on whether position is set
994
+ this.config.noPosition = this.chordConfig.position === undefined;
995
+
996
+ this.closeSettingsDialog();
997
+ this.redraw();
998
+ this.triggerChange();
999
+ }
1000
+
1001
+ /**
1002
+ * Get current chord configuration
1003
+ * @returns {import("svguitar").Chord}
1004
+ */
1005
+ getChord() {
1006
+ return { ...this.chordConfig };
1007
+ }
1008
+
1009
+ /**
1010
+ * Get string representation of the chord
1011
+ * @param {object} [options]
1012
+ * @param {boolean} [options.useUnicode=false] - Whether to use Unicode characters for string/fret markers
1013
+ * @returns {string}
1014
+ */
1015
+ toString(options) {
1016
+ return fingeringToString(this.chordConfig, options);
1017
+ }
1018
+
1019
+ /**
1020
+ * Register a callback for when the chord changes
1021
+ * @param {(this: EditableSVGuitarChord) => void} callback - Called with updated fingers array
1022
+ * @returns {EditableSVGuitarChord}
1023
+ */
1024
+ onChange(callback) {
1025
+ this.changeCallback = callback;
1026
+ return this;
1027
+ }
1028
+
1029
+ /**
1030
+ * Trigger the change callback if registered
1031
+ */
1032
+ triggerChange() {
1033
+ if (this.changeCallback && typeof this.changeCallback === 'function') {
1034
+ // Only pass the fingers array to match the expected format
1035
+ this.changeCallback(this);
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * Clean up resources
1041
+ */
1042
+ destroy() {
1043
+ if (this.dialog && this.dialog.parentNode) {
1044
+ this.dialog.parentNode.removeChild(this.dialog);
1045
+ }
1046
+ if (this.backdrop && this.backdrop.parentNode) {
1047
+ this.backdrop.parentNode.removeChild(this.backdrop);
1048
+ }
1049
+ }
1050
+ }