@parent-tobias/chord-component 1.0.0
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/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/chord-data-service.js +190 -0
- package/dist/chord-diagram.js +181 -0
- package/dist/chord-editor.js +691 -0
- package/dist/chord-list.js +119 -0
- package/dist/default-chords.js +322 -0
- package/dist/index.js +26 -0
- package/dist/indexed-db-service.js +228 -0
- package/dist/music-utils.js +128 -0
- package/dist/node_modules/@lit/reactive-element/css-tag.js +42 -0
- package/dist/node_modules/@lit/reactive-element/decorators/base.js +9 -0
- package/dist/node_modules/@lit/reactive-element/decorators/custom-element.js +13 -0
- package/dist/node_modules/@lit/reactive-element/decorators/property.js +37 -0
- package/dist/node_modules/@lit/reactive-element/decorators/query.js +20 -0
- package/dist/node_modules/@lit/reactive-element/decorators/state.js +12 -0
- package/dist/node_modules/@lit/reactive-element/reactive-element.js +251 -0
- package/package.json +83 -0
- package/src/chord-data-service.ts +275 -0
- package/src/chord-diagram.ts +255 -0
- package/src/chord-editor.ts +919 -0
- package/src/chord-list.ts +145 -0
- package/src/default-chords.ts +333 -0
- package/src/index.ts +7 -0
- package/src/indexed-db-service.ts +356 -0
- package/src/music-utils.ts +216 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import { LitElement, css, html } from 'lit';
|
|
2
|
+
import { customElement, property, state, query } from 'lit/decorators.js';
|
|
3
|
+
import { SVGuitarChord } from 'svguitar';
|
|
4
|
+
import type { Finger, Barre } from 'svguitar';
|
|
5
|
+
|
|
6
|
+
import { instruments, chordOnInstrument, chordToNotes } from './music-utils.js';
|
|
7
|
+
import { chordDataService } from './chord-data-service.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An interactive web component that allows users to edit chord diagrams.
|
|
11
|
+
* Users can click to add/remove finger positions and create barres.
|
|
12
|
+
*
|
|
13
|
+
* @element chord-editor
|
|
14
|
+
*
|
|
15
|
+
* @attr {string} instrument - The instrument to edit the chord for (default: 'Standard Ukulele')
|
|
16
|
+
* @attr {string} chord - The chord name to edit (e.g., 'C', 'Am7', 'F#dim')
|
|
17
|
+
*
|
|
18
|
+
* @fires chord-saved - Fired when user saves the edited chord
|
|
19
|
+
* @fires chord-reset - Fired when user resets to default
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```html
|
|
23
|
+
* <chord-editor chord="C" instrument="Standard Ukulele"></chord-editor>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
@customElement('chord-editor')
|
|
27
|
+
export class ChordEditor extends LitElement {
|
|
28
|
+
|
|
29
|
+
static styles = css`
|
|
30
|
+
:host {
|
|
31
|
+
display: block;
|
|
32
|
+
width: 100%;
|
|
33
|
+
max-width: 400px;
|
|
34
|
+
border: 1px solid #4a5568;
|
|
35
|
+
border-radius: 8px;
|
|
36
|
+
background: #2d3748;
|
|
37
|
+
padding: 1rem;
|
|
38
|
+
box-sizing: border-box;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.editor {
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
gap: 1rem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.header {
|
|
48
|
+
display: flex;
|
|
49
|
+
justify-content: space-between;
|
|
50
|
+
align-items: center;
|
|
51
|
+
border-bottom: 2px solid #4a5568;
|
|
52
|
+
padding-bottom: 0.5rem;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.header h3 {
|
|
56
|
+
color: #90cdf4;
|
|
57
|
+
margin: 0;
|
|
58
|
+
font-size: 1.1rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.badge {
|
|
62
|
+
background: #3182ce;
|
|
63
|
+
color: white;
|
|
64
|
+
padding: 0.25rem 0.5rem;
|
|
65
|
+
border-radius: 4px;
|
|
66
|
+
font-size: 0.75rem;
|
|
67
|
+
font-weight: 600;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.badge.modified {
|
|
71
|
+
background: #f6ad55;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.diagram-container {
|
|
75
|
+
position: relative;
|
|
76
|
+
width: 100%;
|
|
77
|
+
display: flex;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
background: #1a202c;
|
|
80
|
+
border-radius: 4px;
|
|
81
|
+
padding: 1rem;
|
|
82
|
+
cursor: crosshair;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.diagram-container :global(svg) {
|
|
86
|
+
max-width: 100%;
|
|
87
|
+
height: auto;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.controls {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: 0.75rem;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.control-group {
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
gap: 0.5rem;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.control-group label {
|
|
103
|
+
color: #e2e8f0;
|
|
104
|
+
font-size: 0.9rem;
|
|
105
|
+
font-weight: 500;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.button-group {
|
|
109
|
+
display: flex;
|
|
110
|
+
gap: 0.5rem;
|
|
111
|
+
flex-wrap: wrap;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
button {
|
|
115
|
+
padding: 0.5rem 1rem;
|
|
116
|
+
border-radius: 4px;
|
|
117
|
+
border: 1px solid #4a5568;
|
|
118
|
+
background: #1a202c;
|
|
119
|
+
color: #f8f8f8;
|
|
120
|
+
font-size: 0.9rem;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
transition: all 0.2s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
button:hover {
|
|
126
|
+
background: #2d3748;
|
|
127
|
+
border-color: #63b3ed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
button.primary {
|
|
131
|
+
background: #3182ce;
|
|
132
|
+
border-color: #3182ce;
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
button.primary:hover {
|
|
137
|
+
background: #2c5282;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
button.secondary {
|
|
141
|
+
background: #718096;
|
|
142
|
+
border-color: #718096;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
button.secondary:hover {
|
|
146
|
+
background: #4a5568;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
button.danger {
|
|
150
|
+
background: #e53e3e;
|
|
151
|
+
border-color: #e53e3e;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
button.danger:hover {
|
|
155
|
+
background: #c53030;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
button:disabled {
|
|
159
|
+
opacity: 0.5;
|
|
160
|
+
cursor: not-allowed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.mode-selector {
|
|
164
|
+
display: flex;
|
|
165
|
+
gap: 0.5rem;
|
|
166
|
+
background: #1a202c;
|
|
167
|
+
padding: 0.25rem;
|
|
168
|
+
border-radius: 4px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.mode-button {
|
|
172
|
+
flex: 1;
|
|
173
|
+
padding: 0.5rem;
|
|
174
|
+
background: transparent;
|
|
175
|
+
border: none;
|
|
176
|
+
color: #a0aec0;
|
|
177
|
+
font-size: 0.85rem;
|
|
178
|
+
font-weight: 500;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
border-radius: 4px;
|
|
181
|
+
transition: all 0.2s;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.mode-button:hover {
|
|
185
|
+
color: #e2e8f0;
|
|
186
|
+
background: #2d3748;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.mode-button.active {
|
|
190
|
+
background: #3182ce;
|
|
191
|
+
color: white;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.finger-list {
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-direction: column;
|
|
197
|
+
gap: 0.25rem;
|
|
198
|
+
max-height: 150px;
|
|
199
|
+
overflow-y: auto;
|
|
200
|
+
background: #1a202c;
|
|
201
|
+
padding: 0.5rem;
|
|
202
|
+
border-radius: 4px;
|
|
203
|
+
font-family: monospace;
|
|
204
|
+
font-size: 0.85rem;
|
|
205
|
+
color: #e2e8f0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.finger-item {
|
|
209
|
+
display: flex;
|
|
210
|
+
justify-content: space-between;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: 0.5rem;
|
|
213
|
+
padding: 0.25rem 0.5rem;
|
|
214
|
+
background: #2d3748;
|
|
215
|
+
border-radius: 4px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.finger-item button {
|
|
219
|
+
padding: 0.25rem 0.5rem;
|
|
220
|
+
font-size: 0.75rem;
|
|
221
|
+
min-width: 60px;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.finger-inputs {
|
|
225
|
+
display: flex;
|
|
226
|
+
gap: 0.5rem;
|
|
227
|
+
align-items: center;
|
|
228
|
+
flex: 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.finger-inputs input {
|
|
232
|
+
width: 50px;
|
|
233
|
+
padding: 0.25rem 0.5rem;
|
|
234
|
+
background: #1a202c;
|
|
235
|
+
border: 1px solid #4a5568;
|
|
236
|
+
border-radius: 4px;
|
|
237
|
+
color: #f8f8f8;
|
|
238
|
+
font-size: 0.85rem;
|
|
239
|
+
font-family: monospace;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.finger-inputs input:focus {
|
|
243
|
+
outline: none;
|
|
244
|
+
border-color: #63b3ed;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.add-button {
|
|
248
|
+
width: 100%;
|
|
249
|
+
background: #2d5282 !important;
|
|
250
|
+
border-color: #2d5282 !important;
|
|
251
|
+
display: flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
justify-content: center;
|
|
254
|
+
gap: 0.5rem;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.add-button:hover {
|
|
258
|
+
background: #3182ce !important;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.shift-toggle {
|
|
262
|
+
display: flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
gap: 0.5rem;
|
|
265
|
+
padding: 0.5rem;
|
|
266
|
+
background: #1a202c;
|
|
267
|
+
border-radius: 4px;
|
|
268
|
+
cursor: pointer;
|
|
269
|
+
user-select: none;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.shift-toggle:hover {
|
|
273
|
+
background: #2d3748;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.shift-toggle input[type="checkbox"] {
|
|
277
|
+
cursor: pointer;
|
|
278
|
+
width: auto;
|
|
279
|
+
padding: 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.shift-toggle label {
|
|
283
|
+
cursor: pointer;
|
|
284
|
+
margin: 0;
|
|
285
|
+
color: #e2e8f0;
|
|
286
|
+
font-size: 0.85rem;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.error {
|
|
290
|
+
color: #fc8181;
|
|
291
|
+
font-size: 0.8rem;
|
|
292
|
+
text-align: center;
|
|
293
|
+
padding: 0.5rem;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.info {
|
|
297
|
+
color: #90cdf4;
|
|
298
|
+
font-size: 0.8rem;
|
|
299
|
+
text-align: center;
|
|
300
|
+
padding: 0.5rem;
|
|
301
|
+
font-style: italic;
|
|
302
|
+
}
|
|
303
|
+
`
|
|
304
|
+
|
|
305
|
+
@property({ type: String })
|
|
306
|
+
instrument = 'Standard Ukulele';
|
|
307
|
+
|
|
308
|
+
@property({ type: String })
|
|
309
|
+
chord = '';
|
|
310
|
+
|
|
311
|
+
@state()
|
|
312
|
+
private fingers: Finger[] = [];
|
|
313
|
+
|
|
314
|
+
@state()
|
|
315
|
+
private barres: Barre[] = [];
|
|
316
|
+
|
|
317
|
+
@state()
|
|
318
|
+
private viewPosition: number = 1; // Display position only, not saved with chord
|
|
319
|
+
|
|
320
|
+
@state()
|
|
321
|
+
private isLoading = false;
|
|
322
|
+
|
|
323
|
+
@state()
|
|
324
|
+
private isModified = false;
|
|
325
|
+
|
|
326
|
+
@state()
|
|
327
|
+
private editMode: 'finger' | 'barre' | 'remove' = 'finger';
|
|
328
|
+
|
|
329
|
+
// Store original data for future use (e.g., undo functionality)
|
|
330
|
+
// private originalData: InstrumentDefault | null = null;
|
|
331
|
+
|
|
332
|
+
@query('.diagram-container')
|
|
333
|
+
diagramContainer?: HTMLElement;
|
|
334
|
+
|
|
335
|
+
private get numStrings(): number {
|
|
336
|
+
const inst = instruments.find(({ name }) => name === this.instrument);
|
|
337
|
+
return inst?.strings.length || 4;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private get calculatedPosition(): number {
|
|
341
|
+
// Auto-calculate the best starting position based on chord data
|
|
342
|
+
const allFrets = [...this.fingers.map(([, fret]) => typeof fret === 'number' ? fret : 0),
|
|
343
|
+
...this.barres.map(b => typeof b.fret === 'number' ? b.fret : 0)];
|
|
344
|
+
|
|
345
|
+
if (allFrets.length === 0) return 1;
|
|
346
|
+
|
|
347
|
+
const minFret = Math.min(...allFrets.filter(f => f > 0));
|
|
348
|
+
const maxFret = Math.max(...allFrets, 0);
|
|
349
|
+
|
|
350
|
+
// If all notes are in first 4 frets, start at 1
|
|
351
|
+
if (maxFret <= 4) return 1;
|
|
352
|
+
|
|
353
|
+
// Otherwise, start from the lowest fret (or close to it)
|
|
354
|
+
return Math.max(1, minFret);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private get maxFrets(): number {
|
|
358
|
+
const allFrets = [...this.fingers.map(([, fret]) => typeof fret === 'number' ? fret : 0),
|
|
359
|
+
...this.barres.map(b => typeof b.fret === 'number' ? b.fret : 0)];
|
|
360
|
+
const maxFret = Math.max(...allFrets, 0);
|
|
361
|
+
|
|
362
|
+
// Default to 5 frets for a consistent view range
|
|
363
|
+
const defaultRange = 5;
|
|
364
|
+
|
|
365
|
+
// Calculate the minimum range needed to show all notes from the current view position
|
|
366
|
+
const minRange = Math.max(maxFret - this.viewPosition + 1, 4);
|
|
367
|
+
|
|
368
|
+
// Use the default range, but expand if needed to show all notes
|
|
369
|
+
return Math.max(defaultRange, minRange);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async connectedCallback() {
|
|
373
|
+
super.connectedCallback();
|
|
374
|
+
await this.loadChordData();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async updated(changedProperties: Map<string, any>) {
|
|
378
|
+
super.updated(changedProperties);
|
|
379
|
+
|
|
380
|
+
if (changedProperties.has('instrument') || changedProperties.has('chord')) {
|
|
381
|
+
await this.loadChordData();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (changedProperties.has('fingers') || changedProperties.has('barres') || changedProperties.has('viewPosition')) {
|
|
385
|
+
this.renderDiagram();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private async loadChordData() {
|
|
390
|
+
if (!this.chord) return;
|
|
391
|
+
|
|
392
|
+
this.isLoading = true;
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
// Try to get user's custom version first
|
|
396
|
+
const userChord = await chordDataService.getChord(this.instrument, this.chord, true);
|
|
397
|
+
|
|
398
|
+
if (userChord) {
|
|
399
|
+
this.fingers = [...userChord.fingers];
|
|
400
|
+
this.barres = [...userChord.barres];
|
|
401
|
+
// Auto-calculate view position based on chord data
|
|
402
|
+
this.viewPosition = this.calculatedPosition;
|
|
403
|
+
// this.originalData = userChord;
|
|
404
|
+
this.isModified = false;
|
|
405
|
+
} else {
|
|
406
|
+
// Fall back to system default or generate
|
|
407
|
+
const systemChord = await chordDataService.getChord(this.instrument, this.chord, false);
|
|
408
|
+
|
|
409
|
+
if (systemChord) {
|
|
410
|
+
this.fingers = [...systemChord.fingers];
|
|
411
|
+
this.barres = [...systemChord.barres];
|
|
412
|
+
// Auto-calculate view position based on chord data
|
|
413
|
+
this.viewPosition = this.calculatedPosition;
|
|
414
|
+
// this.originalData = systemChord;
|
|
415
|
+
this.isModified = false;
|
|
416
|
+
} else {
|
|
417
|
+
// Generate from music theory
|
|
418
|
+
this.generateDefaultChord();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch (error) {
|
|
422
|
+
console.error('Failed to load chord data:', error);
|
|
423
|
+
this.fingers = [];
|
|
424
|
+
this.barres = [];
|
|
425
|
+
} finally {
|
|
426
|
+
this.isLoading = false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private generateDefaultChord() {
|
|
431
|
+
const instrumentObject = instruments.find(({ name }) => name === this.instrument);
|
|
432
|
+
if (!instrumentObject) return;
|
|
433
|
+
|
|
434
|
+
const chordFinder = chordOnInstrument(instrumentObject);
|
|
435
|
+
const chordObject = chordToNotes(this.chord);
|
|
436
|
+
|
|
437
|
+
if (chordObject && chordObject.notes && chordObject.notes.length > 0) {
|
|
438
|
+
this.fingers = chordFinder(chordObject) || [];
|
|
439
|
+
this.barres = [];
|
|
440
|
+
this.viewPosition = this.calculatedPosition;
|
|
441
|
+
// this.originalData = { fingers: [...this.fingers], barres: [] };
|
|
442
|
+
this.isModified = false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private renderDiagram() {
|
|
447
|
+
if (!this.diagramContainer) return;
|
|
448
|
+
|
|
449
|
+
const instrumentObject = instruments.find(({ name }) => name === this.instrument);
|
|
450
|
+
if (!instrumentObject) return;
|
|
451
|
+
|
|
452
|
+
// Clear existing diagram
|
|
453
|
+
this.diagramContainer.innerHTML = '';
|
|
454
|
+
|
|
455
|
+
const divEl = document.createElement('div');
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
// Convert absolute fret positions to relative positions based on viewPosition
|
|
459
|
+
// SVGuitar expects positions relative to the position parameter
|
|
460
|
+
const relativeFingers = this.fingers
|
|
461
|
+
.map(([string, fret]): Finger | null => {
|
|
462
|
+
if (typeof fret === 'number') {
|
|
463
|
+
const relativeFret = fret - this.viewPosition + 1;
|
|
464
|
+
// Only include fingers that are within the visible range
|
|
465
|
+
if (relativeFret >= 0 && relativeFret <= this.maxFrets) {
|
|
466
|
+
return [string, relativeFret];
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
// Handle 'x' or other non-numeric fret values
|
|
470
|
+
return [string, fret];
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
})
|
|
474
|
+
.filter((f): f is Finger => f !== null);
|
|
475
|
+
|
|
476
|
+
const relativeBarres = this.barres
|
|
477
|
+
.map((barre): Barre | null => {
|
|
478
|
+
if (typeof barre.fret === 'number') {
|
|
479
|
+
const relativeFret = barre.fret - this.viewPosition + 1;
|
|
480
|
+
// Only include barres that are within the visible range
|
|
481
|
+
if (relativeFret >= 0 && relativeFret <= this.maxFrets) {
|
|
482
|
+
return {
|
|
483
|
+
...barre,
|
|
484
|
+
fret: relativeFret
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
})
|
|
490
|
+
.filter((b): b is Barre => b !== null);
|
|
491
|
+
|
|
492
|
+
const chart = new SVGuitarChord(divEl);
|
|
493
|
+
chart
|
|
494
|
+
.configure({
|
|
495
|
+
strings: instrumentObject.strings.length,
|
|
496
|
+
frets: this.maxFrets,
|
|
497
|
+
position: this.viewPosition,
|
|
498
|
+
tuning: [...instrumentObject.strings]
|
|
499
|
+
})
|
|
500
|
+
.chord({
|
|
501
|
+
fingers: relativeFingers,
|
|
502
|
+
barres: relativeBarres
|
|
503
|
+
})
|
|
504
|
+
.draw();
|
|
505
|
+
|
|
506
|
+
if (divEl.firstChild) {
|
|
507
|
+
this.diagramContainer.appendChild(divEl.firstChild);
|
|
508
|
+
this.setupInteraction();
|
|
509
|
+
}
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.error('Error rendering diagram:', error);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private setupInteraction() {
|
|
516
|
+
const svg = this.diagramContainer?.querySelector('svg');
|
|
517
|
+
if (!svg) return;
|
|
518
|
+
|
|
519
|
+
svg.addEventListener('click', (e) => this.handleDiagramClick(e));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private handleDiagramClick(e: MouseEvent) {
|
|
523
|
+
const svg = e.currentTarget as SVGSVGElement;
|
|
524
|
+
const rect = svg.getBoundingClientRect();
|
|
525
|
+
|
|
526
|
+
// Get click position relative to SVG
|
|
527
|
+
const x = e.clientX - rect.left;
|
|
528
|
+
const y = e.clientY - rect.top;
|
|
529
|
+
|
|
530
|
+
// Convert to string and fret coordinates
|
|
531
|
+
// This is a simplified calculation - you may need to adjust based on SVGuitar's rendering
|
|
532
|
+
const stringWidth = rect.width / (this.numStrings + 1);
|
|
533
|
+
const fretHeight = rect.height / (this.maxFrets + 2);
|
|
534
|
+
|
|
535
|
+
const stringNum = Math.round((rect.width - x) / stringWidth);
|
|
536
|
+
let fretNum = Math.round((y - fretHeight) / fretHeight);
|
|
537
|
+
|
|
538
|
+
// Adjust fret number based on view position
|
|
539
|
+
fretNum = fretNum + this.viewPosition - 1;
|
|
540
|
+
|
|
541
|
+
const maxAbsoluteFret = this.viewPosition + this.maxFrets - 1;
|
|
542
|
+
if (stringNum >= 1 && stringNum <= this.numStrings && fretNum >= 0 && fretNum <= maxAbsoluteFret) {
|
|
543
|
+
this.handlePositionClick(stringNum, fretNum);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private handlePositionClick(stringNum: number, fretNum: number) {
|
|
548
|
+
if (this.editMode === 'finger') {
|
|
549
|
+
this.addOrUpdateFinger(stringNum, fretNum);
|
|
550
|
+
} else if (this.editMode === 'remove') {
|
|
551
|
+
this.removeFinger(stringNum);
|
|
552
|
+
}
|
|
553
|
+
// Barre mode would need more complex UI (select range)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private addOrUpdateFinger(stringNum: number, fretNum: number) {
|
|
557
|
+
const existingIndex = this.fingers.findIndex(([s]) => s === stringNum);
|
|
558
|
+
|
|
559
|
+
if (existingIndex >= 0) {
|
|
560
|
+
// Update existing finger
|
|
561
|
+
this.fingers[existingIndex] = [stringNum, fretNum];
|
|
562
|
+
} else {
|
|
563
|
+
// Add new finger
|
|
564
|
+
this.fingers.push([stringNum, fretNum]);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
this.fingers = [...this.fingers]; // Trigger update
|
|
568
|
+
this.isModified = true;
|
|
569
|
+
this.requestUpdate();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private removeFinger(stringNum: number) {
|
|
573
|
+
this.fingers = this.fingers.filter(([s]) => s !== stringNum);
|
|
574
|
+
this.isModified = true;
|
|
575
|
+
this.requestUpdate();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private removeFingerByIndex(index: number) {
|
|
579
|
+
this.fingers.splice(index, 1);
|
|
580
|
+
this.fingers = [...this.fingers];
|
|
581
|
+
this.isModified = true;
|
|
582
|
+
this.requestUpdate();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private async saveChord() {
|
|
586
|
+
if (!this.chord) return;
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
await chordDataService.saveUserChord(
|
|
590
|
+
this.instrument,
|
|
591
|
+
this.chord,
|
|
592
|
+
{
|
|
593
|
+
fingers: this.fingers,
|
|
594
|
+
barres: this.barres
|
|
595
|
+
// position is NOT saved - it's auto-calculated
|
|
596
|
+
}
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
this.isModified = false;
|
|
600
|
+
// this.originalData = { fingers: [...this.fingers], barres: [...this.barres] };
|
|
601
|
+
|
|
602
|
+
// Dispatch event
|
|
603
|
+
this.dispatchEvent(new CustomEvent('chord-saved', {
|
|
604
|
+
detail: {
|
|
605
|
+
instrument: this.instrument,
|
|
606
|
+
chord: this.chord,
|
|
607
|
+
data: { fingers: this.fingers, barres: this.barres }
|
|
608
|
+
},
|
|
609
|
+
bubbles: true,
|
|
610
|
+
composed: true
|
|
611
|
+
}));
|
|
612
|
+
|
|
613
|
+
this.requestUpdate();
|
|
614
|
+
} catch (error) {
|
|
615
|
+
console.error('Failed to save chord:', error);
|
|
616
|
+
alert('Failed to save chord. Please try again.');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private async resetToDefault() {
|
|
621
|
+
if (!confirm('Reset to default chord? This will discard your changes.')) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
await chordDataService.deleteUserChord(this.instrument, this.chord);
|
|
627
|
+
await this.loadChordData();
|
|
628
|
+
|
|
629
|
+
this.dispatchEvent(new CustomEvent('chord-reset', {
|
|
630
|
+
detail: {
|
|
631
|
+
instrument: this.instrument,
|
|
632
|
+
chord: this.chord
|
|
633
|
+
},
|
|
634
|
+
bubbles: true,
|
|
635
|
+
composed: true
|
|
636
|
+
}));
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.error('Failed to reset chord:', error);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private clearAll() {
|
|
643
|
+
this.fingers = [];
|
|
644
|
+
this.barres = [];
|
|
645
|
+
this.isModified = true;
|
|
646
|
+
this.requestUpdate();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private shiftViewPosition(delta: number) {
|
|
650
|
+
// Shift the view position (display window) only
|
|
651
|
+
const newViewPosition = Math.max(1, this.viewPosition + delta);
|
|
652
|
+
if (newViewPosition !== this.viewPosition) {
|
|
653
|
+
this.viewPosition = newViewPosition;
|
|
654
|
+
// Don't mark as modified - this is just a view change
|
|
655
|
+
this.requestUpdate();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private resetViewPosition() {
|
|
660
|
+
// Reset view to auto-calculated position
|
|
661
|
+
this.viewPosition = this.calculatedPosition;
|
|
662
|
+
this.requestUpdate();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private updateFingerString(index: number, value: string) {
|
|
666
|
+
const num = parseInt(value);
|
|
667
|
+
if (!isNaN(num) && num >= 1 && num <= this.numStrings) {
|
|
668
|
+
this.fingers[index] = [num, this.fingers[index][1]];
|
|
669
|
+
this.fingers = [...this.fingers];
|
|
670
|
+
this.isModified = true;
|
|
671
|
+
this.requestUpdate();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private updateFingerFret(index: number, value: string) {
|
|
676
|
+
const num = parseInt(value);
|
|
677
|
+
if (!isNaN(num) && num >= 0) {
|
|
678
|
+
this.fingers[index] = [this.fingers[index][0], num];
|
|
679
|
+
this.fingers = [...this.fingers];
|
|
680
|
+
this.isModified = true;
|
|
681
|
+
this.requestUpdate();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private addNewFinger() {
|
|
686
|
+
// Add a new finger at string 1, fret 0 (open)
|
|
687
|
+
this.fingers.push([1, 0]);
|
|
688
|
+
this.fingers = [...this.fingers];
|
|
689
|
+
this.isModified = true;
|
|
690
|
+
this.requestUpdate();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private addBarre() {
|
|
694
|
+
// Add a new barre from string 4 to 1, at the current view position
|
|
695
|
+
this.barres.push({
|
|
696
|
+
fromString: this.numStrings,
|
|
697
|
+
toString: 1,
|
|
698
|
+
fret: this.viewPosition,
|
|
699
|
+
text: "1"
|
|
700
|
+
});
|
|
701
|
+
this.barres = [...this.barres];
|
|
702
|
+
this.isModified = true;
|
|
703
|
+
this.requestUpdate();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private updateBarreFromString(index: number, value: string) {
|
|
707
|
+
const num = parseInt(value);
|
|
708
|
+
if (!isNaN(num) && num >= 1 && num <= this.numStrings) {
|
|
709
|
+
this.barres[index].fromString = num;
|
|
710
|
+
this.barres = [...this.barres];
|
|
711
|
+
this.isModified = true;
|
|
712
|
+
this.requestUpdate();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private updateBarreToString(index: number, value: string) {
|
|
717
|
+
const num = parseInt(value);
|
|
718
|
+
if (!isNaN(num) && num >= 1 && num <= this.numStrings) {
|
|
719
|
+
this.barres[index].toString = num;
|
|
720
|
+
this.barres = [...this.barres];
|
|
721
|
+
this.isModified = true;
|
|
722
|
+
this.requestUpdate();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private updateBarreFret(index: number, value: string) {
|
|
727
|
+
const num = parseInt(value);
|
|
728
|
+
if (!isNaN(num) && num >= 0) {
|
|
729
|
+
this.barres[index].fret = num;
|
|
730
|
+
this.barres = [...this.barres];
|
|
731
|
+
this.isModified = true;
|
|
732
|
+
this.requestUpdate();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private removeBarreByIndex(index: number) {
|
|
737
|
+
this.barres.splice(index, 1);
|
|
738
|
+
this.barres = [...this.barres];
|
|
739
|
+
this.isModified = true;
|
|
740
|
+
this.requestUpdate();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
render() {
|
|
744
|
+
if (this.isLoading) {
|
|
745
|
+
return html`
|
|
746
|
+
<div class='editor'>
|
|
747
|
+
<div class='info'>Loading...</div>
|
|
748
|
+
</div>
|
|
749
|
+
`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (!this.chord) {
|
|
753
|
+
return html`
|
|
754
|
+
<div class='editor'>
|
|
755
|
+
<div class='error'>No chord specified</div>
|
|
756
|
+
</div>
|
|
757
|
+
`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return html`
|
|
761
|
+
<div class='editor'>
|
|
762
|
+
<div class='header'>
|
|
763
|
+
<h3>${this.chord} - ${this.instrument}</h3>
|
|
764
|
+
${this.isModified ? html`<span class='badge modified'>Modified</span>` : html`<span class='badge'>Saved</span>`}
|
|
765
|
+
</div>
|
|
766
|
+
|
|
767
|
+
<div class='diagram-container'></div>
|
|
768
|
+
|
|
769
|
+
<div class='controls'>
|
|
770
|
+
<div class='control-group'>
|
|
771
|
+
<label>View Position (Display Window: Fret ${this.viewPosition})</label>
|
|
772
|
+
<div class='info' style="margin-bottom: 0.5rem;">
|
|
773
|
+
Adjust which frets are shown. The chord itself stays the same.
|
|
774
|
+
</div>
|
|
775
|
+
<div class='button-group'>
|
|
776
|
+
<button @click=${() => this.shiftViewPosition(-1)}>
|
|
777
|
+
← View Lower
|
|
778
|
+
</button>
|
|
779
|
+
<button @click=${() => this.shiftViewPosition(1)}>
|
|
780
|
+
View Higher →
|
|
781
|
+
</button>
|
|
782
|
+
<button @click=${this.resetViewPosition}>
|
|
783
|
+
Auto Position
|
|
784
|
+
</button>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
|
|
788
|
+
<div class='control-group'>
|
|
789
|
+
<label>Edit Mode</label>
|
|
790
|
+
<div class='mode-selector'>
|
|
791
|
+
<button
|
|
792
|
+
class='mode-button ${this.editMode === 'finger' ? 'active' : ''}'
|
|
793
|
+
@click=${() => this.editMode = 'finger'}
|
|
794
|
+
>
|
|
795
|
+
Add/Edit
|
|
796
|
+
</button>
|
|
797
|
+
<button
|
|
798
|
+
class='mode-button ${this.editMode === 'remove' ? 'active' : ''}'
|
|
799
|
+
@click=${() => this.editMode = 'remove'}
|
|
800
|
+
>
|
|
801
|
+
Remove
|
|
802
|
+
</button>
|
|
803
|
+
</div>
|
|
804
|
+
</div>
|
|
805
|
+
|
|
806
|
+
<div class='control-group'>
|
|
807
|
+
<label>Finger Positions (${this.fingers.length})</label>
|
|
808
|
+
<div class='finger-list'>
|
|
809
|
+
${this.fingers.length === 0 ? html`
|
|
810
|
+
<div class='info'>No finger positions. Click the diagram or use "Add Finger" below.</div>
|
|
811
|
+
` : this.fingers.map((finger, index) => html`
|
|
812
|
+
<div class='finger-item'>
|
|
813
|
+
<div class='finger-inputs'>
|
|
814
|
+
<label style="color: #a0aec0; font-size: 0.75rem;">String:</label>
|
|
815
|
+
<input
|
|
816
|
+
type="number"
|
|
817
|
+
min="1"
|
|
818
|
+
max="${this.numStrings}"
|
|
819
|
+
.value="${finger[0]}"
|
|
820
|
+
@input=${(e: Event) => this.updateFingerString(index, (e.target as HTMLInputElement).value)}
|
|
821
|
+
/>
|
|
822
|
+
<label style="color: #a0aec0; font-size: 0.75rem;">Fret:</label>
|
|
823
|
+
<input
|
|
824
|
+
type="number"
|
|
825
|
+
min="0"
|
|
826
|
+
.value="${finger[1]}"
|
|
827
|
+
@input=${(e: Event) => this.updateFingerFret(index, (e.target as HTMLInputElement).value)}
|
|
828
|
+
/>
|
|
829
|
+
</div>
|
|
830
|
+
<button
|
|
831
|
+
class='danger'
|
|
832
|
+
@click=${() => this.removeFingerByIndex(index)}
|
|
833
|
+
>
|
|
834
|
+
×
|
|
835
|
+
</button>
|
|
836
|
+
</div>
|
|
837
|
+
`)}
|
|
838
|
+
<button class='add-button' @click=${this.addNewFinger}>
|
|
839
|
+
+ Add Finger Position
|
|
840
|
+
</button>
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
<div class='control-group'>
|
|
845
|
+
<label>Barre Positions (${this.barres.length})</label>
|
|
846
|
+
<div class='finger-list'>
|
|
847
|
+
${this.barres.length === 0 ? html`
|
|
848
|
+
<div class='info'>No barres. Use "Add Barre" below to create one.</div>
|
|
849
|
+
` : this.barres.map((barre, index) => html`
|
|
850
|
+
<div class='finger-item'>
|
|
851
|
+
<div class='finger-inputs'>
|
|
852
|
+
<label style="color: #a0aec0; font-size: 0.75rem;">From:</label>
|
|
853
|
+
<input
|
|
854
|
+
type="number"
|
|
855
|
+
min="1"
|
|
856
|
+
max="${this.numStrings}"
|
|
857
|
+
.value="${barre.fromString}"
|
|
858
|
+
@input=${(e: Event) => this.updateBarreFromString(index, (e.target as HTMLInputElement).value)}
|
|
859
|
+
/>
|
|
860
|
+
<label style="color: #a0aec0; font-size: 0.75rem;">To:</label>
|
|
861
|
+
<input
|
|
862
|
+
type="number"
|
|
863
|
+
min="1"
|
|
864
|
+
max="${this.numStrings}"
|
|
865
|
+
.value="${barre.toString}"
|
|
866
|
+
@input=${(e: Event) => this.updateBarreToString(index, (e.target as HTMLInputElement).value)}
|
|
867
|
+
/>
|
|
868
|
+
<label style="color: #a0aec0; font-size: 0.75rem;">Fret:</label>
|
|
869
|
+
<input
|
|
870
|
+
type="number"
|
|
871
|
+
min="0"
|
|
872
|
+
.value="${barre.fret}"
|
|
873
|
+
@input=${(e: Event) => this.updateBarreFret(index, (e.target as HTMLInputElement).value)}
|
|
874
|
+
/>
|
|
875
|
+
</div>
|
|
876
|
+
<button
|
|
877
|
+
class='danger'
|
|
878
|
+
@click=${() => this.removeBarreByIndex(index)}
|
|
879
|
+
>
|
|
880
|
+
×
|
|
881
|
+
</button>
|
|
882
|
+
</div>
|
|
883
|
+
`)}
|
|
884
|
+
<button class='add-button' @click=${this.addBarre}>
|
|
885
|
+
+ Add Barre
|
|
886
|
+
</button>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
<div class='button-group'>
|
|
891
|
+
<button
|
|
892
|
+
class='primary'
|
|
893
|
+
@click=${this.saveChord}
|
|
894
|
+
?disabled=${!this.isModified}
|
|
895
|
+
>
|
|
896
|
+
Save Custom Chord
|
|
897
|
+
</button>
|
|
898
|
+
<button
|
|
899
|
+
class='secondary'
|
|
900
|
+
@click=${this.resetToDefault}
|
|
901
|
+
>
|
|
902
|
+
Reset to Default
|
|
903
|
+
</button>
|
|
904
|
+
<button
|
|
905
|
+
class='danger'
|
|
906
|
+
@click=${this.clearAll}
|
|
907
|
+
>
|
|
908
|
+
Clear All
|
|
909
|
+
</button>
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
|
|
913
|
+
<div class='info'>
|
|
914
|
+
${this.editMode === 'finger' ? 'Click on the diagram to add or update finger positions.' : 'Click on a finger position to remove it.'}
|
|
915
|
+
</div>
|
|
916
|
+
</div>
|
|
917
|
+
`;
|
|
918
|
+
}
|
|
919
|
+
}
|