Code cleanup
This commit is contained in:
@@ -1,130 +1,177 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useState, useRef } from "react";
|
||||||
import "../App.css";
|
import "../styles.css";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const DEMO_TABS = `
|
const DEMO_TABS = `
|
||||||
4 6 -5 5 -4 4 3
|
4 6 -5 5 -4 4 3
|
||||||
6 7 -6 6 -6' 6 5
|
6 7 -6 6 -6' 6 5
|
||||||
4 4 4 5 5' 5 5 5 -5 5 4
|
4 4 4 5 5' 5 5 5 -5 5 4
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const HarmonicaTabGenerator = () => {
|
const SVG_CONFIG = {
|
||||||
const [input, setInput] = useState(DEMO_TABS);
|
lineHeight: 40,
|
||||||
const [title, setTitle] = useState("My Harmonica Tab");
|
fontSize: 28,
|
||||||
const [maxLinesPerColumn, setMaxLinesPerColumn] = useState(10);
|
annotationFontSize: 12,
|
||||||
const svgRef = useRef(null);
|
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) => {
|
// UTILITY FUNCTIONS
|
||||||
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;
|
|
||||||
|
|
||||||
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)
|
return lines.map((line) => {
|
||||||
if (isTabLine) {
|
// Tab lines contain only numbers, spaces, minus signs, and bend markers
|
||||||
processedContent = line.replace(
|
const isTabLine =
|
||||||
/(\s|^)(\d+)(['"`'']?)/g,
|
/^[\s\d\-'"`'']+$/.test(line.trim()) && line.trim().length > 0;
|
||||||
(match, space, number, bend) => {
|
|
||||||
return space + "+" + number + bend;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
let processedContent = line;
|
||||||
content: processedContent,
|
|
||||||
isTab: isTabLine,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate layout (columns based on max lines per column)
|
// Add + signs to positive numbers in tab lines
|
||||||
const calculateLayout = (parsedLines) => {
|
if (isTabLine) {
|
||||||
const tabLines = parsedLines.filter((l) => l.isTab);
|
processedContent = line.replace(
|
||||||
const numColumns = Math.ceil(tabLines.length / maxLinesPerColumn);
|
/(\s|^)(\d+)(['"`'']?)/g,
|
||||||
|
(match, space, number, bend) => space + "+" + number + bend,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 columns = [];
|
||||||
const calculateColumnWidth = (column) => {
|
let currentIndex = 0;
|
||||||
let maxLength = 0;
|
|
||||||
column.forEach((line) => {
|
for (let col = 0; col < numColumns; col++) {
|
||||||
if (line.content.trim()) {
|
const columnLines = [];
|
||||||
// Adjusted for letterSpacing of 0.5 instead of 2
|
let tabCount = 0;
|
||||||
// Monospace at 28px is about 16.8px per char + 0.5px spacing
|
|
||||||
const charWidth = line.isTab ? 17.3 : 7;
|
while (currentIndex < parsedLines.length && tabCount < maxLinesPerColumn) {
|
||||||
const length = line.content.length * charWidth;
|
const line = parsedLines[currentIndex];
|
||||||
maxLength = Math.max(maxLength, length);
|
columnLines.push(line);
|
||||||
|
|
||||||
|
if (line.isTab) {
|
||||||
|
tabCount++;
|
||||||
}
|
}
|
||||||
});
|
currentIndex++;
|
||||||
return Math.max(maxLength, 200); // Minimum 200px per column
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
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(
|
const maxLinesInAnyColumn = Math.max(
|
||||||
...columns.map((col) => {
|
...columns.map((col) => {
|
||||||
return col.reduce((sum, line) => {
|
return col.reduce((sum, line) => sum + (line.isTab ? 1 : 0.5), 0);
|
||||||
return sum + (line.isTab ? 1 : 0.5);
|
|
||||||
}, 0);
|
|
||||||
}),
|
}),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
const svgWidth =
|
const svgWidth =
|
||||||
totalContentWidth + (columns.length - 1) * columnGap + padding * 2;
|
totalContentWidth +
|
||||||
const svgHeight =
|
(columns.length - 1) * SVG_CONFIG.columnGap +
|
||||||
maxLinesInAnyColumn * lineHeight + padding * 2 + titleHeight;
|
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 exportAsPNG = () => {
|
||||||
const svgElement = svgRef.current;
|
const svgElement = svgRef.current;
|
||||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
// Set canvas size (2x for better quality)
|
// Set canvas size with 2x scaling for better quality
|
||||||
canvas.width = svgWidth * 2;
|
canvas.width = svgWidth * SVG_CONFIG.canvasScale;
|
||||||
canvas.height = svgHeight * 2;
|
canvas.height = svgHeight * SVG_CONFIG.canvasScale;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const svgBlob = new Blob([svgData], {
|
const svgBlob = new Blob([svgData], {
|
||||||
@@ -133,7 +180,7 @@ const HarmonicaTabGenerator = () => {
|
|||||||
const url = URL.createObjectURL(svgBlob);
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
ctx.scale(2, 2);
|
ctx.scale(SVG_CONFIG.canvasScale, SVG_CONFIG.canvasScale);
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
@@ -148,7 +195,9 @@ const HarmonicaTabGenerator = () => {
|
|||||||
img.src = url;
|
img.src = url;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export as SVG
|
/**
|
||||||
|
* Export the SVG as an SVG file
|
||||||
|
*/
|
||||||
const exportAsSVG = () => {
|
const exportAsSVG = () => {
|
||||||
const svgElement = svgRef.current;
|
const svgElement = svgRef.current;
|
||||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||||
@@ -159,226 +208,283 @@ const HarmonicaTabGenerator = () => {
|
|||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gradient-bg">
|
<div className="gradient-bg">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
{/* Header */}
|
||||||
<h1 className="title">🎵 Harmonica Tabs Image Generator</h1>
|
<h1 className="title">🎵 Harmonica Tabs Image Generator</h1>
|
||||||
<p className="subtitle">
|
<p className="subtitle">
|
||||||
Create beautiful, readable images of your harmonica tabs
|
Create beautiful, readable images of your harmonica tabs
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Instructions Section - MOVED TO TOP */}
|
{/* Instructions */}
|
||||||
<div className="card instructions-card">
|
<InstructionsSection />
|
||||||
<h2 className="section-title">📋 How to Use</h2>
|
|
||||||
<ul className="instructions">
|
|
||||||
<li className="instruction-item">
|
|
||||||
<span className="instruction-number">1</span>
|
|
||||||
<div className="instruction-content">
|
|
||||||
<strong>Enter your tabs</strong> - Type your harmonica tab
|
|
||||||
numbers with spaces
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="instruction-item">
|
|
||||||
<span className="instruction-number">2</span>
|
|
||||||
<div className="instruction-content">
|
|
||||||
<strong>Auto-formatting</strong> - Positive numbers get a + sign
|
|
||||||
automatically. Bends: ' " ` ' (e.g., -3', 4")
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="instruction-item">
|
|
||||||
<span className="instruction-number">3</span>
|
|
||||||
<div className="instruction-content">
|
|
||||||
<strong>Add annotations</strong> - Plain text appears smaller
|
|
||||||
and italicized
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="instruction-item">
|
|
||||||
<span className="instruction-number">4</span>
|
|
||||||
<div className="instruction-content">
|
|
||||||
<strong>Customize layout</strong> - Set your title and rows per
|
|
||||||
column
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="instruction-item">
|
|
||||||
<span className="instruction-number">5</span>
|
|
||||||
<div className="instruction-content">
|
|
||||||
<strong>Download</strong> - Save as PNG or SVG when ready
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings Section */}
|
{/* Settings */}
|
||||||
<div className="card settings-card">
|
<SettingsSection
|
||||||
<h2 className="section-title">⚙️ Settings</h2>
|
title={title}
|
||||||
<div className="settings-grid">
|
setTitle={setTitle}
|
||||||
<div className="setting-item">
|
maxLinesPerColumn={maxLinesPerColumn}
|
||||||
<label className="setting-label">
|
setMaxLinesPerColumn={setMaxLinesPerColumn}
|
||||||
Tab Title
|
/>
|
||||||
<span className="setting-hint">
|
|
||||||
Appears at the top of your image
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
className="input-field"
|
|
||||||
placeholder="Enter a title for your tabs..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="setting-item">
|
|
||||||
<label className="setting-label">
|
|
||||||
Rows Per Column
|
|
||||||
<span className="setting-hint">
|
|
||||||
How many lines before splitting into columns
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="slider-container">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="5"
|
|
||||||
max="20"
|
|
||||||
value={maxLinesPerColumn}
|
|
||||||
onChange={(e) =>
|
|
||||||
setMaxLinesPerColumn(parseInt(e.target.value))
|
|
||||||
}
|
|
||||||
className="slider"
|
|
||||||
/>
|
|
||||||
<span className="slider-value">{maxLinesPerColumn}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Section */}
|
{/* Input */}
|
||||||
<div className="card">
|
<InputSection
|
||||||
<h2 className="section-title">✏️ Input Your Tabs</h2>
|
input={input}
|
||||||
<textarea
|
setInput={setInput}
|
||||||
value={input}
|
exportAsPNG={exportAsPNG}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
exportAsSVG={exportAsSVG}
|
||||||
className="textarea"
|
/>
|
||||||
placeholder="Enter your harmonica tabs here...
|
|
||||||
|
{/* Preview */}
|
||||||
|
<PreviewSection
|
||||||
|
svgRef={svgRef}
|
||||||
|
svgWidth={svgWidth}
|
||||||
|
svgHeight={svgHeight}
|
||||||
|
title={title}
|
||||||
|
columns={columns}
|
||||||
|
columnWidths={columnWidths}
|
||||||
|
exportAsPNG={exportAsPNG}
|
||||||
|
exportAsSVG={exportAsSVG}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SUB-COMPONENTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const InstructionsSection = () => (
|
||||||
|
<div className="card instructions-card">
|
||||||
|
<h2 className="section-title">📋 How to Use</h2>
|
||||||
|
<ul className="instructions">
|
||||||
|
<li className="instruction-item">
|
||||||
|
<span className="instruction-number">1</span>
|
||||||
|
<div className="instruction-content">
|
||||||
|
<strong>Enter your tabs</strong> - Type your harmonica tab numbers
|
||||||
|
with spaces
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="instruction-item">
|
||||||
|
<span className="instruction-number">2</span>
|
||||||
|
<div className="instruction-content">
|
||||||
|
<strong>Auto-formatting</strong> - Positive numbers get a + sign
|
||||||
|
automatically. Bends: ' " ` ' (e.g., -3', 4")
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="instruction-item">
|
||||||
|
<span className="instruction-number">3</span>
|
||||||
|
<div className="instruction-content">
|
||||||
|
<strong>Add annotations</strong> - Plain text appears smaller and
|
||||||
|
italicized
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="instruction-item">
|
||||||
|
<span className="instruction-number">4</span>
|
||||||
|
<div className="instruction-content">
|
||||||
|
<strong>Customize layout</strong> - Set your title and rows per column
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="instruction-item">
|
||||||
|
<span className="instruction-number">5</span>
|
||||||
|
<div className="instruction-content">
|
||||||
|
<strong>Download</strong> - Save as PNG or SVG when ready
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingsSection = ({
|
||||||
|
title,
|
||||||
|
setTitle,
|
||||||
|
maxLinesPerColumn,
|
||||||
|
setMaxLinesPerColumn,
|
||||||
|
}) => (
|
||||||
|
<div className="card settings-card">
|
||||||
|
<h2 className="section-title">⚙️ Settings</h2>
|
||||||
|
<div className="settings-grid">
|
||||||
|
<div className="setting-item">
|
||||||
|
<label className="setting-label">
|
||||||
|
Tab Title
|
||||||
|
<span className="setting-hint">Appears at the top of your image</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Enter a title for your tabs..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<label className="setting-label">
|
||||||
|
Rows Per Column
|
||||||
|
<span className="setting-hint">
|
||||||
|
How many lines before splitting into columns
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="slider-container">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="5"
|
||||||
|
max="20"
|
||||||
|
value={maxLinesPerColumn}
|
||||||
|
onChange={(e) => setMaxLinesPerColumn(parseInt(e.target.value))}
|
||||||
|
className="slider"
|
||||||
|
/>
|
||||||
|
<span className="slider-value">{maxLinesPerColumn}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InputSection = ({ input, setInput, exportAsPNG, exportAsSVG }) => (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="section-title">✏️ Input Your Tabs</h2>
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
className="textarea"
|
||||||
|
placeholder="Enter your harmonica tabs here...
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
4 6 -5 5 -4 4 3
|
4 6 -5 5 -4 4 3
|
||||||
-3' 6 7 -6' 6 5
|
-3' 6 7 -6' 6 5
|
||||||
|
|
||||||
Add quotes after numbers for bends"
|
Add quotes after numbers for bends"
|
||||||
rows={12}
|
rows={12}
|
||||||
/>
|
/>
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button onClick={exportAsPNG} className="button button-primary">
|
<button onClick={exportAsPNG} className="button button-primary">
|
||||||
📥 Download PNG
|
📥 Download PNG
|
||||||
</button>
|
</button>
|
||||||
<button onClick={exportAsSVG} className="button button-secondary">
|
<button onClick={exportAsSVG} className="button button-secondary">
|
||||||
📥 Download SVG
|
📥 Download SVG
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<p className="tip">
|
|
||||||
💡 <strong>Pro tip:</strong> Mix tab lines with text for
|
|
||||||
annotations. Tab lines contain only numbers, spaces, and bend
|
|
||||||
markers (' " ` ').
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview Section */}
|
|
||||||
<div className="card preview-card">
|
|
||||||
<h2 className="section-title">👁️ Preview</h2>
|
|
||||||
<div className="preview-container">
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
width={svgWidth}
|
|
||||||
height={svgHeight}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
style={{ background: "#fffef5", display: "block" }}
|
|
||||||
>
|
|
||||||
{/* Background with soft cream color */}
|
|
||||||
<rect width={svgWidth} height={svgHeight} fill="#fffef5" />
|
|
||||||
|
|
||||||
{/* Title - centered at top */}
|
|
||||||
{title && (
|
|
||||||
<text
|
|
||||||
x={svgWidth / 2}
|
|
||||||
y={padding + titleFontSize}
|
|
||||||
fontFamily="'Segoe UI', 'Arial Rounded MT Bold', Arial, sans-serif"
|
|
||||||
fontSize={titleFontSize}
|
|
||||||
fontWeight="700"
|
|
||||||
fill="#5c4a72"
|
|
||||||
textAnchor="middle"
|
|
||||||
letterSpacing="1"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Render columns */}
|
|
||||||
{columns.map((column, colIndex) => {
|
|
||||||
let yOffset = padding + titleHeight;
|
|
||||||
// Calculate xOffset based on cumulative widths of previous columns
|
|
||||||
let xOffset = padding;
|
|
||||||
for (let i = 0; i < colIndex; i++) {
|
|
||||||
xOffset += columnWidths[i] + columnGap;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={colIndex}>
|
|
||||||
{column.map((line, lineIndex) => {
|
|
||||||
const currentY = yOffset;
|
|
||||||
|
|
||||||
if (line.isTab) {
|
|
||||||
// Render tab line with monospace font - softer color
|
|
||||||
yOffset += lineHeight;
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={lineIndex}
|
|
||||||
x={xOffset}
|
|
||||||
y={currentY}
|
|
||||||
fontFamily="'Courier New', monospace"
|
|
||||||
fontSize={fontSize}
|
|
||||||
fontWeight="600"
|
|
||||||
fill="#3d5a6c"
|
|
||||||
letterSpacing="0.5"
|
|
||||||
>
|
|
||||||
{line.content}
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
} else if (line.content.trim()) {
|
|
||||||
// Render annotation with smaller font - soft gray
|
|
||||||
yOffset += lineHeight * 0.6;
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={lineIndex}
|
|
||||||
x={xOffset}
|
|
||||||
y={currentY}
|
|
||||||
fontFamily="Arial, sans-serif"
|
|
||||||
fontSize={annotationFontSize}
|
|
||||||
fontStyle="italic"
|
|
||||||
fill="#8b7e99"
|
|
||||||
>
|
|
||||||
{line.content}
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="button-group">
|
|
||||||
<button onClick={exportAsPNG} className="button button-primary">
|
|
||||||
📥 Download PNG
|
|
||||||
</button>
|
|
||||||
<button onClick={exportAsSVG} className="button button-secondary">
|
|
||||||
📥 Download SVG
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<p className="tip">
|
||||||
};
|
💡 <strong>Pro tip:</strong> Mix tab lines with text for annotations. Tab
|
||||||
|
lines contain only numbers, spaces, and bend markers (' " ` ').
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PreviewSection = ({
|
||||||
|
svgRef,
|
||||||
|
svgWidth,
|
||||||
|
svgHeight,
|
||||||
|
title,
|
||||||
|
columns,
|
||||||
|
columnWidths,
|
||||||
|
exportAsPNG,
|
||||||
|
exportAsSVG,
|
||||||
|
}) => (
|
||||||
|
<div className="card preview-card">
|
||||||
|
<h2 className="section-title">👁️ Preview</h2>
|
||||||
|
<div className="preview-container">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
width={svgWidth}
|
||||||
|
height={svgHeight}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ background: SVG_CONFIG.backgroundColor, display: "block" }}
|
||||||
|
>
|
||||||
|
{/* Background */}
|
||||||
|
<rect
|
||||||
|
width={svgWidth}
|
||||||
|
height={svgHeight}
|
||||||
|
fill={SVG_CONFIG.backgroundColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<text
|
||||||
|
x={svgWidth / 2}
|
||||||
|
y={SVG_CONFIG.padding + SVG_CONFIG.titleFontSize}
|
||||||
|
fontFamily="'Segoe UI', 'Arial Rounded MT Bold', Arial, sans-serif"
|
||||||
|
fontSize={SVG_CONFIG.titleFontSize}
|
||||||
|
fontWeight="700"
|
||||||
|
fill="#5c4a72"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="1"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Columns */}
|
||||||
|
{columns.map((column, colIndex) => {
|
||||||
|
let yOffset = SVG_CONFIG.padding + SVG_CONFIG.titleHeight;
|
||||||
|
|
||||||
|
// Calculate x position based on previous columns
|
||||||
|
let xOffset = SVG_CONFIG.padding;
|
||||||
|
for (let i = 0; i < colIndex; i++) {
|
||||||
|
xOffset += columnWidths[i] + SVG_CONFIG.columnGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={colIndex}>
|
||||||
|
{column.map((line, lineIndex) => {
|
||||||
|
const currentY = yOffset;
|
||||||
|
|
||||||
|
if (line.isTab) {
|
||||||
|
// Tab line - monospace, larger font
|
||||||
|
yOffset += SVG_CONFIG.lineHeight;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={lineIndex}
|
||||||
|
x={xOffset}
|
||||||
|
y={currentY}
|
||||||
|
fontFamily="'Courier New', monospace"
|
||||||
|
fontSize={SVG_CONFIG.fontSize}
|
||||||
|
fontWeight="600"
|
||||||
|
fill="#3d5a6c"
|
||||||
|
letterSpacing="0.5"
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
} else if (line.content.trim()) {
|
||||||
|
// Annotation line - smaller, italic
|
||||||
|
yOffset += SVG_CONFIG.lineHeight * 0.6;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={lineIndex}
|
||||||
|
x={xOffset}
|
||||||
|
y={currentY}
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
fontSize={SVG_CONFIG.annotationFontSize}
|
||||||
|
fontStyle="italic"
|
||||||
|
fill="#8b7e99"
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="button-group">
|
||||||
|
<button onClick={exportAsPNG} className="button button-primary">
|
||||||
|
📥 Download PNG
|
||||||
|
</button>
|
||||||
|
<button onClick={exportAsSVG} className="button button-secondary">
|
||||||
|
📥 Download SVG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default HarmonicaTabGenerator;
|
export default HarmonicaTabGenerator;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/* Game Freak-inspired soft pastel color scheme */
|
/* ============================================================================
|
||||||
|
CSS VARIABLES - Color Palette
|
||||||
|
============================================================================ */
|
||||||
:root {
|
:root {
|
||||||
--cream: #fffef5;
|
--cream: #fffef5;
|
||||||
--soft-purple: #5c4a72;
|
--soft-purple: #5c4a72;
|
||||||
@@ -12,6 +14,9 @@
|
|||||||
--accent-mint: #b5ead7;
|
--accent-mint: #b5ead7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
GLOBAL STYLES
|
||||||
|
============================================================================ */
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -29,6 +34,9 @@ body {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
LAYOUT
|
||||||
|
============================================================================ */
|
||||||
.gradient-bg {
|
.gradient-bg {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
@@ -46,6 +54,9 @@ body {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================================================ */
|
||||||
.title {
|
.title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -64,6 +75,19 @@ body {
|
|||||||
font-weight: 500;
|
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 {
|
.card {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -84,6 +108,7 @@ body {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Card Variants */
|
||||||
.instructions-card {
|
.instructions-card {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
@@ -109,16 +134,9 @@ body {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
/* ============================================================================
|
||||||
font-size: 1.5rem;
|
INSTRUCTIONS
|
||||||
font-weight: 700;
|
============================================================================ */
|
||||||
color: var(--soft-purple);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions {
|
.instructions {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -169,6 +187,9 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
SETTINGS
|
||||||
|
============================================================================ */
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -205,6 +226,9 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
FORM INPUTS
|
||||||
|
============================================================================ */
|
||||||
.input-field {
|
.input-field {
|
||||||
padding: 0.875rem 1.125rem;
|
padding: 0.875rem 1.125rem;
|
||||||
border: 2px solid var(--soft-gray);
|
border: 2px solid var(--soft-gray);
|
||||||
@@ -223,6 +247,29 @@ body {
|
|||||||
box-shadow: 0 0 0 4px rgba(137, 184, 212, 0.1);
|
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 {
|
.slider-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -293,26 +340,9 @@ body {
|
|||||||
box-shadow: 0 2px 8px rgba(92, 74, 114, 0.2);
|
box-shadow: 0 2px 8px rgba(92, 74, 114, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
/* ============================================================================
|
||||||
width: 100%;
|
BUTTONS
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
.button-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -362,25 +392,9 @@ body {
|
|||||||
background: linear-gradient(135deg, #99c4dc, var(--pastel-blue));
|
background: linear-gradient(135deg, #99c4dc, var(--pastel-blue));
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
/* ============================================================================
|
||||||
margin-top: 1rem;
|
PREVIEW
|
||||||
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-container {
|
.preview-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -402,7 +416,54 @@ body {
|
|||||||
flex-shrink: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.title {
|
.title {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
@@ -425,24 +486,3 @@ body {
|
|||||||
padding: 1rem;
|
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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user