diff --git a/src/components/HarmonicaTabGenerator.jsx b/src/components/HarmonicaTabGenerator.jsx
index b1d57f2..d482629 100644
--- a/src/components/HarmonicaTabGenerator.jsx
+++ b/src/components/HarmonicaTabGenerator.jsx
@@ -1,130 +1,177 @@
import React, { useState, useRef } from "react";
-import "../App.css";
+import "../styles.css";
+
+// ============================================================================
+// CONSTANTS
+// ============================================================================
const DEMO_TABS = `
4 6 -5 5 -4 4 3
6 7 -6 6 -6' 6 5
4 4 4 5 5' 5 5 5 -5 5 4
- `;
+`;
-const HarmonicaTabGenerator = () => {
- const [input, setInput] = useState(DEMO_TABS);
- const [title, setTitle] = useState("My Harmonica Tab");
- const [maxLinesPerColumn, setMaxLinesPerColumn] = useState(10);
- const svgRef = useRef(null);
+const SVG_CONFIG = {
+ lineHeight: 40,
+ fontSize: 28,
+ annotationFontSize: 12,
+ titleFontSize: 36,
+ titleHeight: 80,
+ padding: 40,
+ columnGap: 80,
+ charWidth: 17.3, // For tab lines (monospace)
+ textCharWidth: 7, // For annotation lines
+ minColumnWidth: 200,
+ canvasScale: 2, // For PNG export quality
+ backgroundColor: "#fffef5",
+};
- // Parse input and add + signs to positive numbers
- const parseInput = (text) => {
- const lines = text.split("\n");
- return lines.map((line) => {
- // Check if line contains tab notation (numbers with optional minus signs, quotes/backticks for bends, and spaces)
- const isTabLine =
- /^[\s\d\-'"`'']+$/.test(line.trim()) && line.trim().length > 0;
+// ============================================================================
+// UTILITY FUNCTIONS
+// ============================================================================
- let processedContent = line;
+/**
+ * Parse input text and identify tab lines vs annotation lines
+ * Adds + signs to positive numbers in tab lines
+ */
+const parseInput = (text) => {
+ const lines = text.split("\n");
- // If it's a tab line, add + signs to positive numbers (but preserve bend notation)
- if (isTabLine) {
- processedContent = line.replace(
- /(\s|^)(\d+)(['"`'']?)/g,
- (match, space, number, bend) => {
- return space + "+" + number + bend;
- },
- );
- }
+ return lines.map((line) => {
+ // Tab lines contain only numbers, spaces, minus signs, and bend markers
+ const isTabLine =
+ /^[\s\d\-'"`'']+$/.test(line.trim()) && line.trim().length > 0;
- return {
- content: processedContent,
- isTab: isTabLine,
- };
- });
- };
+ let processedContent = line;
- // Calculate layout (columns based on max lines per column)
- const calculateLayout = (parsedLines) => {
- const tabLines = parsedLines.filter((l) => l.isTab);
- const numColumns = Math.ceil(tabLines.length / maxLinesPerColumn);
-
- const columns = [];
- let currentIndex = 0;
-
- for (let col = 0; col < numColumns; col++) {
- const columnLines = [];
- let tabCount = 0;
-
- while (
- currentIndex < parsedLines.length &&
- tabCount < maxLinesPerColumn
- ) {
- const line = parsedLines[currentIndex];
- columnLines.push(line);
-
- if (line.isTab) {
- tabCount++;
- }
- currentIndex++;
- }
-
- columns.push(columnLines);
+ // Add + signs to positive numbers in tab lines
+ if (isTabLine) {
+ processedContent = line.replace(
+ /(\s|^)(\d+)(['"`'']?)/g,
+ (match, space, number, bend) => space + "+" + number + bend,
+ );
}
- return columns;
- };
+ return {
+ content: processedContent,
+ isTab: isTabLine,
+ };
+ });
+};
- const parsedLines = parseInput(input);
- const columns = calculateLayout(parsedLines);
+/**
+ * Calculate column layout based on max lines per column
+ */
+const calculateLayout = (parsedLines, maxLinesPerColumn) => {
+ const tabLines = parsedLines.filter((l) => l.isTab);
+ const numColumns = Math.ceil(tabLines.length / maxLinesPerColumn);
- // Calculate the width of each column based on content
- const calculateColumnWidth = (column) => {
- let maxLength = 0;
- column.forEach((line) => {
- if (line.content.trim()) {
- // Adjusted for letterSpacing of 0.5 instead of 2
- // Monospace at 28px is about 16.8px per char + 0.5px spacing
- const charWidth = line.isTab ? 17.3 : 7;
- const length = line.content.length * charWidth;
- maxLength = Math.max(maxLength, length);
+ const columns = [];
+ let currentIndex = 0;
+
+ for (let col = 0; col < numColumns; col++) {
+ const columnLines = [];
+ let tabCount = 0;
+
+ while (currentIndex < parsedLines.length && tabCount < maxLinesPerColumn) {
+ const line = parsedLines[currentIndex];
+ columnLines.push(line);
+
+ if (line.isTab) {
+ tabCount++;
}
- });
- return Math.max(maxLength, 200); // Minimum 200px per column
- };
+ currentIndex++;
+ }
- const columnWidths = columns.map(calculateColumnWidth);
+ columns.push(columnLines);
+ }
+
+ return columns;
+};
+
+/**
+ * Calculate the width needed for a column based on its content
+ */
+const calculateColumnWidth = (column) => {
+ let maxLength = 0;
+
+ column.forEach((line) => {
+ if (line.content.trim()) {
+ const charWidth = line.isTab
+ ? SVG_CONFIG.charWidth
+ : SVG_CONFIG.textCharWidth;
+ const length = line.content.length * charWidth;
+ maxLength = Math.max(maxLength, length);
+ }
+ });
+
+ return Math.max(maxLength, SVG_CONFIG.minColumnWidth);
+};
+
+/**
+ * Calculate SVG dimensions based on content
+ */
+const calculateSVGDimensions = (columns, columnWidths) => {
const totalContentWidth = columnWidths.reduce((sum, width) => sum + width, 0);
- // SVG dimensions and styling
- const lineHeight = 40;
- const fontSize = 28;
- const annotationFontSize = 12;
- const titleFontSize = 36;
- const titleHeight = 80;
- const padding = 40;
- const columnGap = 80;
-
const maxLinesInAnyColumn = Math.max(
...columns.map((col) => {
- return col.reduce((sum, line) => {
- return sum + (line.isTab ? 1 : 0.5);
- }, 0);
+ return col.reduce((sum, line) => sum + (line.isTab ? 1 : 0.5), 0);
}),
1,
);
const svgWidth =
- totalContentWidth + (columns.length - 1) * columnGap + padding * 2;
- const svgHeight =
- maxLinesInAnyColumn * lineHeight + padding * 2 + titleHeight;
+ totalContentWidth +
+ (columns.length - 1) * SVG_CONFIG.columnGap +
+ SVG_CONFIG.padding * 2;
- // Export as PNG
+ const svgHeight =
+ maxLinesInAnyColumn * SVG_CONFIG.lineHeight +
+ SVG_CONFIG.padding * 2 +
+ SVG_CONFIG.titleHeight;
+
+ return { svgWidth, svgHeight };
+};
+
+// ============================================================================
+// MAIN COMPONENT
+// ============================================================================
+
+const HarmonicaTabGenerator = () => {
+ // State
+ const [input, setInput] = useState(DEMO_TABS);
+ const [title, setTitle] = useState("My Harmonica Tab");
+ const [maxLinesPerColumn, setMaxLinesPerColumn] = useState(10);
+
+ // Ref for SVG element
+ const svgRef = useRef(null);
+
+ // ============================================================================
+ // COMPUTED VALUES
+ // ============================================================================
+
+ const parsedLines = parseInput(input);
+ const columns = calculateLayout(parsedLines, maxLinesPerColumn);
+ const columnWidths = columns.map(calculateColumnWidth);
+ const { svgWidth, svgHeight } = calculateSVGDimensions(columns, columnWidths);
+
+ // ============================================================================
+ // EVENT HANDLERS
+ // ============================================================================
+
+ /**
+ * Export the SVG as a PNG image
+ */
const exportAsPNG = () => {
const svgElement = svgRef.current;
const svgData = new XMLSerializer().serializeToString(svgElement);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
- // Set canvas size (2x for better quality)
- canvas.width = svgWidth * 2;
- canvas.height = svgHeight * 2;
+ // Set canvas size with 2x scaling for better quality
+ canvas.width = svgWidth * SVG_CONFIG.canvasScale;
+ canvas.height = svgHeight * SVG_CONFIG.canvasScale;
const img = new Image();
const svgBlob = new Blob([svgData], {
@@ -133,7 +180,7 @@ const HarmonicaTabGenerator = () => {
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
- ctx.scale(2, 2);
+ ctx.scale(SVG_CONFIG.canvasScale, SVG_CONFIG.canvasScale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
@@ -148,7 +195,9 @@ const HarmonicaTabGenerator = () => {
img.src = url;
};
- // Export as SVG
+ /**
+ * Export the SVG as an SVG file
+ */
const exportAsSVG = () => {
const svgElement = svgRef.current;
const svgData = new XMLSerializer().serializeToString(svgElement);
@@ -159,226 +208,283 @@ const HarmonicaTabGenerator = () => {
link.click();
};
+ // ============================================================================
+ // RENDER
+ // ============================================================================
+
return (
+ {/* Header */}
🎵 Harmonica Tabs Image Generator
Create beautiful, readable images of your harmonica tabs
- {/* Instructions Section - MOVED TO TOP */}
-
-
📋 How to Use
-
- -
- 1
-
- Enter your tabs - Type your harmonica tab
- numbers with spaces
-
-
- -
- 2
-
- Auto-formatting - Positive numbers get a + sign
- automatically. Bends: ' " ` ' (e.g., -3', 4")
-
-
- -
- 3
-
- Add annotations - Plain text appears smaller
- and italicized
-
-
- -
- 4
-
- Customize layout - Set your title and rows per
- column
-
-
- -
- 5
-
- Download - Save as PNG or SVG when ready
-
-
-
-
+ {/* Instructions */}
+
- {/* Settings Section */}
-
-
⚙️ Settings
-
-
-
- setTitle(e.target.value)}
- className="input-field"
- placeholder="Enter a title for your tabs..."
- />
-
-
-
-
-
- setMaxLinesPerColumn(parseInt(e.target.value))
- }
- className="slider"
- />
- {maxLinesPerColumn}
-
-
-
-
+ {/* Settings */}
+
- {/* Input Section */}
-
+
+ );
+};
+
+// ============================================================================
+// SUB-COMPONENTS
+// ============================================================================
+
+const InstructionsSection = () => (
+
+
📋 How to Use
+
+ -
+ 1
+
+ Enter your tabs - Type your harmonica tab numbers
+ with spaces
+
+
+ -
+ 2
+
+ Auto-formatting - Positive numbers get a + sign
+ automatically. Bends: ' " ` ' (e.g., -3', 4")
+
+
+ -
+ 3
+
+ Add annotations - Plain text appears smaller and
+ italicized
+
+
+ -
+ 4
+
+ Customize layout - Set your title and rows per column
+
+
+ -
+ 5
+
+ Download - Save as PNG or SVG when ready
+
+
+
+
+);
+
+const SettingsSection = ({
+ title,
+ setTitle,
+ maxLinesPerColumn,
+ setMaxLinesPerColumn,
+}) => (
+
+
⚙️ Settings
+
+
+
+ setTitle(e.target.value)}
+ className="input-field"
+ placeholder="Enter a title for your tabs..."
+ />
+
+
+
+
+ setMaxLinesPerColumn(parseInt(e.target.value))}
+ className="slider"
+ />
+ {maxLinesPerColumn}
+
+
+
+
+);
+
+const InputSection = ({ input, setInput, exportAsPNG, exportAsSVG }) => (
+
-
- {/* Preview Section */}
-
-
👁️ Preview
-
-
-
-
-
-
-
-
-
+ rows={12}
+ />
+
+
+
- );
-};
+
+ 💡 Pro tip: Mix tab lines with text for annotations. Tab
+ lines contain only numbers, spaces, and bend markers (' " ` ').
+
+
+);
+
+const PreviewSection = ({
+ svgRef,
+ svgWidth,
+ svgHeight,
+ title,
+ columns,
+ columnWidths,
+ exportAsPNG,
+ exportAsSVG,
+}) => (
+
+
👁️ Preview
+
+
+
+
+
+
+
+
+);
export default HarmonicaTabGenerator;
diff --git a/src/App.css b/src/styles.css
similarity index 76%
rename from src/App.css
rename to src/styles.css
index 642f054..751345a 100644
--- a/src/App.css
+++ b/src/styles.css
@@ -1,4 +1,6 @@
-/* Game Freak-inspired soft pastel color scheme */
+/* ============================================================================
+ CSS VARIABLES - Color Palette
+ ============================================================================ */
:root {
--cream: #fffef5;
--soft-purple: #5c4a72;
@@ -12,6 +14,9 @@
--accent-mint: #b5ead7;
}
+/* ============================================================================
+ GLOBAL STYLES
+ ============================================================================ */
* {
margin: 0;
padding: 0;
@@ -29,6 +34,9 @@ body {
line-height: 1.6;
}
+/* ============================================================================
+ LAYOUT
+ ============================================================================ */
.gradient-bg {
min-height: 100vh;
background: linear-gradient(
@@ -46,6 +54,9 @@ body {
margin: 0 auto;
}
+/* ============================================================================
+ TYPOGRAPHY
+ ============================================================================ */
.title {
font-size: 2.5rem;
font-weight: 800;
@@ -64,6 +75,19 @@ body {
font-weight: 500;
}
+.section-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--soft-purple);
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* ============================================================================
+ CARDS
+ ============================================================================ */
.card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
@@ -84,6 +108,7 @@ body {
transform: translateY(-2px);
}
+/* Card Variants */
.instructions-card {
background: linear-gradient(
135deg,
@@ -109,16 +134,9 @@ body {
);
}
-.section-title {
- font-size: 1.5rem;
- font-weight: 700;
- color: var(--soft-purple);
- margin-bottom: 1.5rem;
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
+/* ============================================================================
+ INSTRUCTIONS
+ ============================================================================ */
.instructions {
list-style: none;
display: flex;
@@ -169,6 +187,9 @@ body {
font-weight: 600;
}
+/* ============================================================================
+ SETTINGS
+ ============================================================================ */
.settings-grid {
display: grid;
grid-template-columns: 1fr;
@@ -205,6 +226,9 @@ body {
font-style: italic;
}
+/* ============================================================================
+ FORM INPUTS
+ ============================================================================ */
.input-field {
padding: 0.875rem 1.125rem;
border: 2px solid var(--soft-gray);
@@ -223,6 +247,29 @@ body {
box-shadow: 0 0 0 4px rgba(137, 184, 212, 0.1);
}
+.textarea {
+ width: 100%;
+ padding: 1rem;
+ border: 2px solid var(--soft-gray);
+ border-radius: 16px;
+ font-family: "Courier New", monospace;
+ font-size: 1rem;
+ line-height: 1.8;
+ resize: vertical;
+ background: white;
+ color: var(--text-primary);
+ transition: all 0.2s ease;
+}
+
+.textarea:focus {
+ outline: none;
+ border-color: var(--pastel-blue);
+ box-shadow: 0 0 0 4px rgba(137, 184, 212, 0.1);
+}
+
+/* ============================================================================
+ SLIDER
+ ============================================================================ */
.slider-container {
display: flex;
align-items: center;
@@ -293,26 +340,9 @@ body {
box-shadow: 0 2px 8px rgba(92, 74, 114, 0.2);
}
-.textarea {
- width: 100%;
- padding: 1rem;
- border: 2px solid var(--soft-gray);
- border-radius: 16px;
- font-family: "Courier New", monospace;
- font-size: 1rem;
- line-height: 1.8;
- resize: vertical;
- background: white;
- color: var(--text-primary);
- transition: all 0.2s ease;
-}
-
-.textarea:focus {
- outline: none;
- border-color: var(--pastel-blue);
- box-shadow: 0 0 0 4px rgba(137, 184, 212, 0.1);
-}
-
+/* ============================================================================
+ BUTTONS
+ ============================================================================ */
.button-group {
display: flex;
gap: 1rem;
@@ -362,25 +392,9 @@ body {
background: linear-gradient(135deg, #99c4dc, var(--pastel-blue));
}
-.tip {
- margin-top: 1rem;
- padding: 1rem;
- background: linear-gradient(
- 135deg,
- rgba(255, 154, 162, 0.1) 0%,
- rgba(181, 234, 215, 0.1) 100%
- );
- border-radius: 12px;
- color: var(--text-secondary);
- font-size: 0.95rem;
- border-left: 4px solid var(--accent-coral);
-}
-
-.tip strong {
- color: var(--soft-purple);
- font-weight: 600;
-}
-
+/* ============================================================================
+ PREVIEW
+ ============================================================================ */
.preview-container {
display: flex;
align-items: flex-start;
@@ -402,7 +416,54 @@ body {
flex-shrink: 0;
}
-/* Mobile responsive */
+/* ============================================================================
+ TIP
+ ============================================================================ */
+.tip {
+ margin-top: 1rem;
+ padding: 1rem;
+ background: linear-gradient(
+ 135deg,
+ rgba(255, 154, 162, 0.1) 0%,
+ rgba(181, 234, 215, 0.1) 100%
+ );
+ border-radius: 12px;
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+ border-left: 4px solid var(--accent-coral);
+}
+
+.tip strong {
+ color: var(--soft-purple);
+ font-weight: 600;
+}
+
+/* ============================================================================
+ SCROLLBAR
+ ============================================================================ */
+::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--soft-gray);
+ border-radius: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--pastel-blue);
+ border-radius: 8px;
+ border: 2px solid var(--soft-gray);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--soft-purple);
+}
+
+/* ============================================================================
+ RESPONSIVE
+ ============================================================================ */
@media (max-width: 768px) {
.title {
font-size: 2rem;
@@ -425,24 +486,3 @@ body {
padding: 1rem;
}
}
-
-/* Scrollbar styling */
-::-webkit-scrollbar {
- width: 12px;
- height: 12px;
-}
-
-::-webkit-scrollbar-track {
- background: var(--soft-gray);
- border-radius: 8px;
-}
-
-::-webkit-scrollbar-thumb {
- background: var(--pastel-blue);
- border-radius: 8px;
- border: 2px solid var(--soft-gray);
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: var(--soft-purple);
-}