feat: tappable chord names in ChordChart, extractUniqueChords
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user