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.
- package/AGENTS.md +18 -0
- package/FORMAT.md +277 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/docs/app.js +172 -0
- package/docs/bundle.js +8712 -0
- package/docs/bundle.js.map +7 -0
- package/docs/index.html +93 -0
- package/index.js +7 -0
- package/lib/editableSVGuitar.js +1050 -0
- package/lib/fingeringToString.js +261 -0
- package/lib/layoutChordStrings.js +92 -0
- package/lib/splitStringInRectangles.js +132 -0
- package/lib/stringToFingering.js +435 -0
- package/package.json +43 -0
- package/scripts/build.js +30 -0
- package/test/editableSVGuitar.test.js +444 -0
- package/test/fingeringToString.test.js +705 -0
- package/test/layoutChordStrings.test.js +193 -0
- package/test/splitStringInRectangles.test.js +98 -0
- package/test/stringToFingering.test.js +1086 -0
- package/tsconfig.json +25 -0
- package/types/editableSVGuitar.d.ts +209 -0
- package/types/fingeringToString.d.ts +10 -0
- package/types/layoutChordStrings.d.ts +10 -0
- package/types/splitStringInRectangles.d.ts +18 -0
- package/types/stringToFingering.d.ts +10 -0
|
@@ -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
|
+
}
|