@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.
@@ -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
+ }