feat: tappable chord names in ChordChart, extractUniqueChords

This commit is contained in:
2026-04-09 00:42:24 +02:00
parent ec7237e8c7
commit e99b581a43
2 changed files with 66 additions and 30 deletions

View File

@@ -1,27 +1,16 @@
import type { LyricLine, Section } from "~/lib/types"; import type { LyricLine, Section } from "~/lib/types";
// Max characters per rendered line. 38 fits comfortably on a 375px phone
// at 14px monospace (≈8.4px per char with padding).
const MAX_WIDTH = 38; const MAX_WIDTH = 38;
interface Props { interface Props {
sections: Section[]; sections: Section[];
fontSize?: 'sm' | 'base' | 'lg'; fontSize?: 'sm' | 'base' | 'lg';
} onChordClick?: (chord: string) => void;
function buildChordRow(chords: { offset: number; chord: string }[]): string {
let row = "";
for (const { offset, chord } of chords) {
while (row.length < offset) row += " ";
row += chord;
}
return row;
} }
/** Split one LyricLine into segments that each fit within maxWidth characters. */ /** Split one LyricLine into segments that each fit within maxWidth characters. */
function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] { function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
const { text, chords } = line; const { text, chords } = line;
if (text.length <= maxWidth) return [line]; if (text.length <= maxWidth) return [line];
const segments: LyricLine[] = []; const segments: LyricLine[] = [];
@@ -29,39 +18,61 @@ function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
while (start < text.length) { while (start < text.length) {
let end = start + maxWidth; let end = start + maxWidth;
if (end < text.length) { if (end < text.length) {
// Break at last space before maxWidth to avoid splitting mid-word
const breakAt = text.lastIndexOf(" ", end); const breakAt = text.lastIndexOf(" ", end);
if (breakAt > start) end = breakAt + 1; if (breakAt > start) end = breakAt + 1;
} else { } else {
end = text.length; end = text.length;
} }
const segText = text.slice(start, end).trimEnd(); const segText = text.slice(start, end).trimEnd();
// Include chords whose offset falls within this segment; re-map offset
const segChords = chords const segChords = chords
.filter((cp) => cp.offset >= start && cp.offset < end) .filter((cp) => cp.offset >= start && cp.offset < end)
.map((cp) => ({ ...cp, offset: cp.offset - start })); .map((cp) => ({ ...cp, offset: cp.offset - start }));
segments.push({ text: segText, chords: segChords }); segments.push({ text: segText, chords: segChords });
// Advance past the break point, skipping leading spaces for next segment
start = end; start = end;
while (start < text.length && text[start] === " ") start++; while (start < text.length && text[start] === " ") start++;
} }
return segments; return segments;
} }
function LineBlock({ line, sizeClass }: { line: LyricLine; sizeClass: string }) { function ChordRow({
chords,
sizeClass,
onChordClick,
}: {
chords: { offset: number; chord: string }[];
sizeClass: string;
onChordClick?: (chord: string) => void;
}) {
return (
<div className={`relative font-mono ${sizeClass} text-primary`} style={{ height: '1.5em' }}>
{chords.map(({ offset, chord }, i) => (
<span
key={i}
className="absolute cursor-pointer hover:underline"
style={{ left: `${offset}ch` }}
onClick={() => onChordClick?.(chord)}
>
{chord}
</span>
))}
</div>
);
}
function LineBlock({
line,
sizeClass,
onChordClick,
}: {
line: LyricLine;
sizeClass: string;
onChordClick?: (chord: string) => void;
}) {
return ( return (
<div className="leading-tight"> <div className="leading-tight">
{line.chords.length > 0 && ( {line.chords.length > 0 && (
<pre className={`text-primary ${sizeClass} font-mono whitespace-pre`}> <ChordRow chords={line.chords} sizeClass={sizeClass} onChordClick={onChordClick} />
{buildChordRow(line.chords)}
</pre>
)} )}
{line.text && ( {line.text && (
<pre className={`text-foreground ${sizeClass} font-mono whitespace-pre`}> <pre className={`text-foreground ${sizeClass} font-mono whitespace-pre`}>
@@ -72,7 +83,15 @@ function LineBlock({ line, sizeClass }: { line: LyricLine; sizeClass: string })
); );
} }
function SectionBlock({ section, sizeClass }: { section: Section; sizeClass: string }) { function SectionBlock({
section,
sizeClass,
onChordClick,
}: {
section: Section;
sizeClass: string;
onChordClick?: (chord: string) => void;
}) {
return ( return (
<div className="mb-6"> <div className="mb-6">
{section.label && ( {section.label && (
@@ -80,19 +99,19 @@ function SectionBlock({ section, sizeClass }: { section: Section; sizeClass: str
)} )}
{section.lines.flatMap((line, i) => {section.lines.flatMap((line, i) =>
segmentLine(line, MAX_WIDTH).map((seg, j) => ( segmentLine(line, MAX_WIDTH).map((seg, j) => (
<LineBlock key={`${i}-${j}`} line={seg} sizeClass={sizeClass} /> <LineBlock key={`${i}-${j}`} line={seg} sizeClass={sizeClass} onChordClick={onChordClick} />
)) ))
)} )}
</div> </div>
); );
} }
export function ChordChart({ sections, fontSize }: Props) { export function ChordChart({ sections, fontSize, onChordClick }: Props) {
const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm']; const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm'];
return ( return (
<div className="px-4 py-3"> <div className="px-4 py-3">
{sections.map((section, i) => ( {sections.map((section, i) => (
<SectionBlock key={i} section={section} sizeClass={sizeClass} /> <SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} />
))} ))}
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import type { Song } from "./types"; import type { Song, Section } from "./types";
export function previewChords(song: Song): string[] { export function previewChords(song: Song): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
@@ -16,3 +16,20 @@ export function previewChords(song: Song): string[] {
} }
return result.slice(0, 5); return result.slice(0, 5);
} }
/** All unique chord names in order of first appearance across all sections. */
export function extractUniqueChords(sections: Section[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const section of sections) {
for (const line of section.lines) {
for (const cp of line.chords) {
if (!seen.has(cp.chord)) {
seen.add(cp.chord);
result.push(cp.chord);
}
}
}
}
return result;
}