initial public release.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
interface FileImportModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onFileImport: (file: File) => void;
|
||||
acceptedTypes?: string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const FileImportModal: React.FC<FileImportModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
onFileImport,
|
||||
acceptedTypes = ['.json'],
|
||||
title = 'Import Project',
|
||||
description = 'Drag and drop your project file here'
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only set drag over to false if we're leaving the drop zone entirely
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
|
||||
// Check if file type is accepted
|
||||
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (acceptedTypes.includes(fileExtension)) {
|
||||
onFileImport(file);
|
||||
onClose();
|
||||
} else {
|
||||
alert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}, [acceptedTypes, onFileImport, onClose]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileImport(files[0]);
|
||||
onClose();
|
||||
}
|
||||
}, [onFileImport, onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||
// Only close if clicking on the overlay itself, not the modal content
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-import-overlay" onClick={handleOverlayClick}>
|
||||
<div className="file-import-modal">
|
||||
<div className="file-import-header">
|
||||
<h3 className="file-import-title">{title}</h3>
|
||||
<button
|
||||
className="file-import-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="Close import modal"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`file-import-drop-zone ${isDragOver ? 'drag-over' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="file-import-drop-content">
|
||||
<div className="file-import-icon">📁</div>
|
||||
<p className="file-import-description">{description}</p>
|
||||
<p className="file-import-formats">
|
||||
Supported formats: {acceptedTypes.join(', ')}
|
||||
</p>
|
||||
|
||||
<div className="file-import-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<label className="file-import-browse-btn">
|
||||
Browse Files
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptedTypes.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileImportModal;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { FaCaretDown } from 'react-icons/fa';
|
||||
|
||||
type DropdownOption = string | { label: string; value: string };
|
||||
|
||||
interface KGDropdownProps {
|
||||
options: DropdownOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
optionClassName?: string;
|
||||
showValueAsLabel?: boolean;
|
||||
hideButton?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const KGDropdown: React.FC<KGDropdownProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
className = '',
|
||||
buttonClassName = '',
|
||||
optionClassName = '',
|
||||
showValueAsLabel = false,
|
||||
hideButton = false,
|
||||
isOpen: externalIsOpen,
|
||||
onToggle
|
||||
}) => {
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use external state if provided, otherwise use internal state
|
||||
const isOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen;
|
||||
const setIsOpen = onToggle || setInternalIsOpen;
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
isOpen &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle option selection
|
||||
const handleSelect = (option: DropdownOption) => {
|
||||
const value = typeof option === 'string' ? option : option.value;
|
||||
onChange(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const resolveLabel = (option: DropdownOption) => (typeof option === 'string' ? option : option.label);
|
||||
const resolveValue = (option: DropdownOption) => (typeof option === 'string' ? option : option.value);
|
||||
|
||||
const selectedLabel = (() => {
|
||||
if (!showValueAsLabel) return label;
|
||||
// Try to find the label for the current value
|
||||
const match = options.find(opt => resolveValue(opt) === value);
|
||||
return match ? resolveLabel(match) : value;
|
||||
})();
|
||||
|
||||
const buttonText = showValueAsLabel ? selectedLabel : label;
|
||||
|
||||
return (
|
||||
<div className={`quant-dropdown-container ${className}`} ref={dropdownRef}>
|
||||
{!hideButton && (
|
||||
<button
|
||||
className={`quant-button ${buttonClassName}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{buttonText} <FaCaretDown />
|
||||
</button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div className="quant-dropdown">
|
||||
{options.map((option) => {
|
||||
const optionValue = resolveValue(option);
|
||||
return (
|
||||
<div
|
||||
key={optionValue}
|
||||
className={`quant-option ${value === optionValue ? 'active' : ''} ${optionClassName}`}
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
{resolveLabel(option)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KGDropdown;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = 'Loading ...' }) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="global-loading-overlay" role="status" aria-live="polite" aria-busy={true}>
|
||||
<div className="global-loading-content">
|
||||
<div className="global-loading-spinner" />
|
||||
<div className="global-loading-text">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
|
||||
interface PlayheadProps {
|
||||
/** Context where the playhead is being rendered */
|
||||
context: 'main-grid' | 'piano-roll';
|
||||
/** For piano roll context, the region start beat offset */
|
||||
regionStartBeat?: number;
|
||||
}
|
||||
|
||||
const Playhead: React.FC<PlayheadProps> = ({ context, regionStartBeat = 0 }) => {
|
||||
const { timeSignature, playheadPosition } = useProjectStore();
|
||||
|
||||
// Calculate the pixel position based on context
|
||||
const getPixelPosition = (): number => {
|
||||
if (context === 'main-grid') {
|
||||
// In main grid, convert beats to bars, then bars to pixels
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const barPosition = playheadPosition / beatsPerBar;
|
||||
|
||||
// Get bar width from CSS variable
|
||||
const barWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||
) || 40;
|
||||
|
||||
return barPosition * barWidth;
|
||||
} else {
|
||||
// In piano roll, use beat-based positioning
|
||||
// Get beat width from CSS variable
|
||||
const beatWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
|
||||
) || 40;
|
||||
|
||||
return playheadPosition * beatWidth;
|
||||
}
|
||||
};
|
||||
|
||||
const pixelPosition = getPixelPosition();
|
||||
|
||||
// Don't render if position is negative (before region start in piano roll)
|
||||
if (pixelPosition < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playheadStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: `${pixelPosition}px`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '2px',
|
||||
backgroundColor: '#4ECDC4', // Blue-green color similar to the reference image
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none', // Allow clicks to pass through
|
||||
boxShadow: '0 0 4px rgba(78, 205, 196, 0.5)', // Subtle glow effect
|
||||
};
|
||||
|
||||
// Triangle indicator style (only for main-grid context)
|
||||
const triangleStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: `${pixelPosition - 5}px`, // Center the triangle on the playhead line
|
||||
top: '-2px', // Position slightly above the top
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '8px solid #4ECDC4',
|
||||
zIndex: 1001,
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="playhead" style={playheadStyle} />
|
||||
{context === 'main-grid' && (
|
||||
<div className="playhead-triangle" style={triangleStyle} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playhead;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
type PianoIconProps = React.SVGProps<SVGSVGElement>;
|
||||
|
||||
/**
|
||||
* PianoIcon – keyboard-style icon in a Font Awesome-like solid style
|
||||
* - Uses currentColor
|
||||
* - Scales with font size (1em)
|
||||
* - viewBox matches FA dimensions
|
||||
*/
|
||||
const PianoIcon: React.FC<PianoIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 576 512"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{/*
|
||||
Build a frame with even-odd fill, then add inner black keys as filled bars.
|
||||
Outer frame: 48,64 → 528x320
|
||||
Inner hole: 96,112 → 384x224
|
||||
Top slot: 112,128 → 352x32
|
||||
Black keys: four bars centered
|
||||
*/}
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="
|
||||
M48 64h480v320H48V64z
|
||||
M96 112h384v224H96V112z
|
||||
M112 128h352v32H112v-32z
|
||||
M176 160h24v136h-24V160z
|
||||
M240 160h24v136h-24V160z
|
||||
M304 160h24v136h-24V160z
|
||||
M368 160h24v136h-24V160z
|
||||
"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default PianoIcon;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as KGDropdown } from './KGDropdown';
|
||||
export { default as Playhead } from './Playhead';
|
||||
export { default as FileImportModal } from './FileImportModal';
|
||||
export { default as LoadingOverlay } from './LoadingOverlay';
|
||||
Reference in New Issue
Block a user