feat: tappable chord names in ChordChart, extractUniqueChords
This commit is contained in:
@@ -1,27 +1,16 @@
|
||||
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;
|
||||
|
||||
interface Props {
|
||||
sections: Section[];
|
||||
fontSize?: 'sm' | 'base' | 'lg';
|
||||
}
|
||||
|
||||
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;
|
||||
onChordClick?: (chord: string) => void;
|
||||
}
|
||||
|
||||
/** Split one LyricLine into segments that each fit within maxWidth characters. */
|
||||
function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
|
||||
const { text, chords } = line;
|
||||
|
||||
if (text.length <= maxWidth) return [line];
|
||||
|
||||
const segments: LyricLine[] = [];
|
||||
@@ -29,39 +18,61 @@ function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
|
||||
|
||||
while (start < text.length) {
|
||||
let end = start + maxWidth;
|
||||
|
||||
if (end < text.length) {
|
||||
// Break at last space before maxWidth to avoid splitting mid-word
|
||||
const breakAt = text.lastIndexOf(" ", end);
|
||||
if (breakAt > start) end = breakAt + 1;
|
||||
} else {
|
||||
end = text.length;
|
||||
}
|
||||
|
||||
const segText = text.slice(start, end).trimEnd();
|
||||
|
||||
// Include chords whose offset falls within this segment; re-map offset
|
||||
const segChords = chords
|
||||
.filter((cp) => cp.offset >= start && cp.offset < end)
|
||||
.map((cp) => ({ ...cp, offset: cp.offset - start }));
|
||||
|
||||
segments.push({ text: segText, chords: segChords });
|
||||
|
||||
// Advance past the break point, skipping leading spaces for next segment
|
||||
start = end;
|
||||
while (start < text.length && text[start] === " ") start++;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="leading-tight">
|
||||
{line.chords.length > 0 && (
|
||||
<pre className={`text-primary ${sizeClass} font-mono whitespace-pre`}>
|
||||
{buildChordRow(line.chords)}
|
||||
</pre>
|
||||
<ChordRow chords={line.chords} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||
)}
|
||||
{line.text && (
|
||||
<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 (
|
||||
<div className="mb-6">
|
||||
{section.label && (
|
||||
@@ -80,19 +99,19 @@ function SectionBlock({ section, sizeClass }: { section: Section; sizeClass: str
|
||||
)}
|
||||
{section.lines.flatMap((line, i) =>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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'];
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
{sections.map((section, i) => (
|
||||
<SectionBlock key={i} section={section} sizeClass={sizeClass} />
|
||||
<SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Song } from "./types";
|
||||
import type { Song, Section } from "./types";
|
||||
|
||||
export function previewChords(song: Song): string[] {
|
||||
const seen = new Set<string>();
|
||||
@@ -16,3 +16,20 @@ export function previewChords(song: Song): string[] {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user