From e0063815b551237f0290e6781f283208bfc6dc8f Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Sat, 11 Oct 2025 04:07:25 +0300 Subject: [PATCH 1/7] perf: enable minification and keepNames for optimal bundle size - Enable minify: true to reduce bundle size by ~46% - Add keepNames: true to preserve function names for debugging - Package size reduced from 188 KB to 157 KB (gzipped) - Unpacked size reduced from 1.0 MB to 560 KB - TypeScript declarations remain fully readable - All IDE features (autocomplete, types) work perfectly --- tsup.config.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tsup.config.ts b/tsup.config.ts index 44b013b..c2823bb 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -15,13 +15,14 @@ export default defineConfig({ 'shared/index': 'src/shared/index.ts', }, format: ['esm', 'cjs'], - dts: true, - sourcemap: false, + dts: true, // Generate full TypeScript declarations + sourcemap: false, // No source maps in production clean: true, target: 'es2020', treeshake: true, - minify: false, // Don't minify - code should be readable + minify: true, // Minify JS for smaller bundle, types stay readable splitting: false, + keepNames: true, // Keep function/class names for better debugging external: [ 'react', 'react-dom', From 67bfb9aa8bde70dc6a040d40cfaa7cd02d05a6e8 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Sat, 11 Oct 2025 04:08:35 +0300 Subject: [PATCH 2/7] style: format code with prettier --- src/input-color-picker/utils/color-utils.ts | 104 +++++++++++--------- 1 file changed, 55 insertions(+), 49 deletions(-) diff --git a/src/input-color-picker/utils/color-utils.ts b/src/input-color-picker/utils/color-utils.ts index 0c8810e..df6dca2 100644 --- a/src/input-color-picker/utils/color-utils.ts +++ b/src/input-color-picker/utils/color-utils.ts @@ -84,66 +84,72 @@ export const hexToRgb = (hexStr: string): RGB | null => { * Конвертирует прозрачность (0-100) в HEX (00-FF) */ export const opacityToHex = (opacity: number): string => { - if (opacity < 0 || opacity > 100) return 'ff' - const alpha = Math.round((opacity / 100) * 255) - return alpha.toString(16).padStart(2, '0') + if (opacity < 0 || opacity > 100) return 'ff' + const alpha = Math.round((opacity / 100) * 255) + return alpha.toString(16).padStart(2, '0') } /** * Создает display color с учетом прозрачности */ export const createDisplayColor = ( - livePreviewColor: string, - opacityValue: number + livePreviewColor: string, + opacityValue: number ): string => { - let displayColor = - livePreviewColor && - (livePreviewColor.includes('gradient') || - livePreviewColor.startsWith('rgba') || - livePreviewColor.startsWith('rgb') || - livePreviewColor.startsWith('#')) - ? livePreviewColor - : '#FFFFFF' - - // Обработка градиентов - возвращаем как есть - // Opacity для градиентов применяется напрямую к rgba цветам в handleOpacityChange - if (displayColor.includes('gradient')) { - return displayColor - } - - // Нормализация rgb/rgba -> rgba с учётом текущей opacityValue - if (displayColor.startsWith('rgb')) { - const match = displayColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/) - if (match) { - const r = parseInt(match[1], 10) - const g = parseInt(match[2], 10) - const b = parseInt(match[3], 10) - const a = match[4] !== undefined ? parseFloat(match[4]) : undefined - const finalA = isNaN(opacityValue) ? (typeof a === 'number' ? a : 1) : opacityValue / 100 - displayColor = `rgba(${r}, ${g}, ${b}, ${finalA})` - } - } - - // HEX -> HEXA с учётом opacityValue - if (displayColor.startsWith('#')) { - let hexVal = displayColor.substring(1) - if (hexVal.length === 3 || hexVal.length === 4) { - hexVal = hexVal - .split('') - .map(char => char + char) - .join('') - } - const rgbHex = hexVal.substring(0, 6) - displayColor = `#${rgbHex}${opacityToHex(opacityValue)}` - } - - return displayColor + let displayColor = + livePreviewColor && + (livePreviewColor.includes('gradient') || + livePreviewColor.startsWith('rgba') || + livePreviewColor.startsWith('rgb') || + livePreviewColor.startsWith('#')) + ? livePreviewColor + : '#FFFFFF' + + // Обработка градиентов - возвращаем как есть + // Opacity для градиентов применяется напрямую к rgba цветам в handleOpacityChange + if (displayColor.includes('gradient')) { + return displayColor + } + + // Нормализация rgb/rgba -> rgba с учётом текущей opacityValue + if (displayColor.startsWith('rgb')) { + const match = displayColor.match( + /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/ + ) + if (match) { + const r = parseInt(match[1], 10) + const g = parseInt(match[2], 10) + const b = parseInt(match[3], 10) + const a = match[4] !== undefined ? parseFloat(match[4]) : undefined + const finalA = isNaN(opacityValue) + ? typeof a === 'number' + ? a + : 1 + : opacityValue / 100 + displayColor = `rgba(${r}, ${g}, ${b}, ${finalA})` + } + } + + // HEX -> HEXA с учётом opacityValue + if (displayColor.startsWith('#')) { + let hexVal = displayColor.substring(1) + if (hexVal.length === 3 || hexVal.length === 4) { + hexVal = hexVal + .split('') + .map(char => char + char) + .join('') + } + const rgbHex = hexVal.substring(0, 6) + displayColor = `#${rgbHex}${opacityToHex(opacityValue)}` + } + + return displayColor } /** * Фильтрует ввод для HEX значения */ export const filterHexInput = (value: string): string => { - const filteredValue = value.replace(/[^0-9a-fA-F]/g, '') - return filteredValue.substring(0, 6).toUpperCase() + const filteredValue = value.replace(/[^0-9a-fA-F]/g, '') + return filteredValue.substring(0, 6).toUpperCase() } From 1a6c229534dceabfbf9f0e6fedbafbad98ea4daa Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Sat, 11 Oct 2025 04:08:44 +0300 Subject: [PATCH 3/7] chore(release): v1.3.1 - optimize bundle size --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 350ed63..2cd13bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@flowscape-ui/design-system-kit", - "version": "1.0.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@flowscape-ui/design-system-kit", - "version": "1.0.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "clsx": "^2.1.1", diff --git a/package.json b/package.json index d703825..3f1580b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flowscape-ui/design-system-kit", - "version": "1.3.0", + "version": "1.3.1", "description": "Professional React UI components for color management and design systems. Features ColorPicker with gradients, InputColorPicker, InputHex with alpha channel, InputNumberSelect, and more. Built with TypeScript.", "type": "module", "main": "./dist/index.cjs", From cfb885a45e9017d2d89a8e2d7afef6a7875f6461 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Sun, 12 Oct 2025 09:22:06 +0300 Subject: [PATCH 4/7] feat: input-image-select & input-upload-image | refactor components --- package.json | 5 + src/color-picker/components/Picker.tsx | 1 + src/color-picker/components/opacity.tsx | 2 +- src/index.ts | 1 + src/input-color-picker/hooks/index.ts | 2 +- .../hooks/use-color-picker-state.ts | 2 +- src/input-color-picker/index.tsx | 2 - src/input-color-picker/input-color-picker.tsx | 271 ++-------- .../input-hex-with-preview.tsx | 191 ++----- src/input-hex/input-hex.tsx | 182 ++----- .../components/image-picker-modal.tsx | 489 ++++++++++++++++++ src/input-image-select/index.tsx | 3 + src/input-image-select/input-image-select.tsx | 167 ++++++ src/input-image-select/utils/index.ts | 1 + src/input-image-select/utils/rotate-image.ts | 131 +++++ .../input-number-select.tsx | 42 +- src/input-upload-image/index.tsx | 2 + src/input-upload-image/input-upload-image.tsx | 96 ++++ src/input/input.tsx | 33 +- .../action-buttons/action-buttons.tsx | 90 ++++ .../color-preview/color-preview.tsx | 101 ++++ src/shared/components/divider/divider.tsx | 5 + .../components/drag-button/drag-button.tsx | 67 +++ .../components/icon-button/icon-button.tsx | 76 +++ .../components/image-input/constants/theme.ts | 23 + .../image-input/hooks/use-file-upload.ts | 61 +++ .../image-input/hooks/use-image-actions.ts | 48 ++ .../image-input/hooks/use-image-state.ts | 51 ++ src/shared/components/image-input/index.ts | 31 ++ .../components/image-input/types/index.ts | 78 +++ .../components/image-input/utils/file-name.ts | 50 ++ .../image-input/utils/file-reader.ts | 47 ++ .../image-preview/image-preview.tsx | 99 ++++ .../input-container/input-container.tsx | 67 +++ .../input-select-modal/input-select-modal.tsx | 90 ++++ .../opacity-drag-control.tsx | 77 +++ .../components/text-button/text-button.tsx | 62 +++ src/shared/constants/input-theme.ts | 27 + src/shared/hooks/use-draggable-input.ts | 101 ++++ .../hooks/use-draggable.ts | 2 +- src/shared/hooks/use-hex-input.ts | 97 ++++ src/shared/index.ts | 51 ++ src/shared/utils/color-utils.ts | 208 ++++++++ src/stories/ColorPicker.stories.tsx | 45 +- src/stories/CombinedDemo.stories.tsx | 122 ----- src/stories/InputImageSelect.stories.tsx | 348 +++++++++++++ src/stories/InputUploadImage.stories.tsx | 114 ++++ 47 files changed, 3179 insertions(+), 682 deletions(-) create mode 100644 src/input-image-select/components/image-picker-modal.tsx create mode 100644 src/input-image-select/index.tsx create mode 100644 src/input-image-select/input-image-select.tsx create mode 100644 src/input-image-select/utils/index.ts create mode 100644 src/input-image-select/utils/rotate-image.ts create mode 100644 src/input-upload-image/index.tsx create mode 100644 src/input-upload-image/input-upload-image.tsx create mode 100644 src/shared/components/action-buttons/action-buttons.tsx create mode 100644 src/shared/components/color-preview/color-preview.tsx create mode 100644 src/shared/components/divider/divider.tsx create mode 100644 src/shared/components/drag-button/drag-button.tsx create mode 100644 src/shared/components/icon-button/icon-button.tsx create mode 100644 src/shared/components/image-input/constants/theme.ts create mode 100644 src/shared/components/image-input/hooks/use-file-upload.ts create mode 100644 src/shared/components/image-input/hooks/use-image-actions.ts create mode 100644 src/shared/components/image-input/hooks/use-image-state.ts create mode 100644 src/shared/components/image-input/index.ts create mode 100644 src/shared/components/image-input/types/index.ts create mode 100644 src/shared/components/image-input/utils/file-name.ts create mode 100644 src/shared/components/image-input/utils/file-reader.ts create mode 100644 src/shared/components/image-preview/image-preview.tsx create mode 100644 src/shared/components/input-container/input-container.tsx create mode 100644 src/shared/components/input-select-modal/input-select-modal.tsx create mode 100644 src/shared/components/opacity-drag-control/opacity-drag-control.tsx create mode 100644 src/shared/components/text-button/text-button.tsx create mode 100644 src/shared/constants/input-theme.ts create mode 100644 src/shared/hooks/use-draggable-input.ts rename src/{input-color-picker => shared}/hooks/use-draggable.ts (94%) create mode 100644 src/shared/hooks/use-hex-input.ts create mode 100644 src/shared/utils/color-utils.ts delete mode 100644 src/stories/CombinedDemo.stories.tsx create mode 100644 src/stories/InputImageSelect.stories.tsx create mode 100644 src/stories/InputUploadImage.stories.tsx diff --git a/package.json b/package.json index 3f1580b..c97013f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,11 @@ "types": "./dist/input-hex-with-preview/index.d.ts", "import": "./dist/input-hex-with-preview/index.js", "require": "./dist/input-hex-with-preview/index.cjs" + }, + "./input-image-select": { + "types": "./dist/input-image-select/index.d.ts", + "import": "./dist/input-image-select/index.js", + "require": "./dist/input-image-select/index.cjs" } }, "files": [ diff --git a/src/color-picker/components/Picker.tsx b/src/color-picker/components/Picker.tsx index 3d33f36..8610515 100644 --- a/src/color-picker/components/Picker.tsx +++ b/src/color-picker/components/Picker.tsx @@ -31,6 +31,7 @@ const Picker = ({ return (
diff --git a/src/color-picker/components/opacity.tsx b/src/color-picker/components/opacity.tsx index 7ecbabd..494422d 100644 --- a/src/color-picker/components/opacity.tsx +++ b/src/color-picker/components/opacity.tsx @@ -36,7 +36,7 @@ const Opacity = () => { x = Math.max(0, Math.min(x, boundingBox.current.width)) const newO = x / boundingBox.current.width - const newColor = `rgba(${r}, ${g}, ${b}, ${newO})` + const newColor = `rgba(${r}, ${g}, ${b}, ${newO.toFixed(2)})` handleChange(newColor) const handleLeft = Math.max(0, Math.min(x - 9, squareWidth - 18)) diff --git a/src/index.ts b/src/index.ts index 9350a5d..2887f2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from './input' export * from './input-color-picker' export * from './input-hex' export * from './input-hex-with-preview' +export * from './input-image-select' export * from './input-number-select' export * from './shared' diff --git a/src/input-color-picker/hooks/index.ts b/src/input-color-picker/hooks/index.ts index 906396d..f9d5af3 100644 --- a/src/input-color-picker/hooks/index.ts +++ b/src/input-color-picker/hooks/index.ts @@ -1,2 +1,2 @@ export * from './use-color-picker-state' -export * from './use-draggable' + diff --git a/src/input-color-picker/hooks/use-color-picker-state.ts b/src/input-color-picker/hooks/use-color-picker-state.ts index 01f3b3b..50a9cbb 100644 --- a/src/input-color-picker/hooks/use-color-picker-state.ts +++ b/src/input-color-picker/hooks/use-color-picker-state.ts @@ -4,7 +4,7 @@ import { hexToRgb, parseColor, rgbToHex, -} from '../utils/color-utils' +} from '../../shared/utils/color-utils' interface UseColorPickerStateProps { value: string diff --git a/src/input-color-picker/index.tsx b/src/input-color-picker/index.tsx index 3aa6ce5..75fcf7c 100644 --- a/src/input-color-picker/index.tsx +++ b/src/input-color-picker/index.tsx @@ -1,4 +1,2 @@ export { InputColorPicker } from './input-color-picker' export type { InputColorPickerProps } from './types' -export * from './utils' -export * from './hooks' diff --git a/src/input-color-picker/input-color-picker.tsx b/src/input-color-picker/input-color-picker.tsx index 2fa71d9..84a18f3 100644 --- a/src/input-color-picker/input-color-picker.tsx +++ b/src/input-color-picker/input-color-picker.tsx @@ -1,105 +1,17 @@ -import { Eye, EyeOff, Trash2, X } from 'lucide-react' import { useRef, useState } from 'react' import { ColorPicker } from '../color-picker' import { InputHex } from '../input-hex' +import { ActionButtons } from '../shared/components/action-buttons/action-buttons' +import { ColorPreview } from '../shared/components/color-preview/color-preview' +import { TextButton } from '../shared/components/text-button/text-button' +import { useImageActions } from '../shared/components/image-input' +import { InputSelectModal } from '../shared/components/input-select-modal/input-select-modal' +import { INPUT_THEME_CLASSES } from '../shared/constants/input-theme' +import { useDraggable } from '../shared/hooks/use-draggable' import { cn } from '../shared/utils/cn' +import { createDisplayColor, opacityToHex } from '../shared/utils/color-utils' import { useColorPickerState } from './hooks/use-color-picker-state' -import { useDraggable } from './hooks/use-draggable' import type { InputColorPickerProps } from './types' -import { createDisplayColor, opacityToHex } from './utils/color-utils' - -const THEME_CLASSES = { - light: { - container: 'bg-white border-gray-300 focus-within:ring-blue-500', - text: 'text-gray-900', - textMuted: 'text-gray-600', - input: 'text-gray-900', - icon: 'text-gray-600', - dragArea: 'bg-gray-100 hover:bg-gray-200', - preview: 'border-gray-300', - divider: 'bg-gray-300', - }, - dark: { - container: - 'dark:bg-gray-800 dark:border-gray-600 dark:focus-within:ring-blue-400', - text: 'dark:text-gray-200', - textMuted: 'dark:text-gray-400', - input: 'dark:text-gray-100', - icon: 'dark:text-gray-300', - dragArea: 'dark:bg-gray-700 dark:hover:bg-gray-600', - preview: 'dark:border-gray-600', - divider: 'dark:bg-gray-600', - }, -} as const - -interface OpacityDragControlProps { - opacity: number - onChange: (value: number) => void -} - -const OpacityDragControl = ({ opacity, onChange }: OpacityDragControlProps) => { - const [isDragging, setIsDragging] = useState(false) - - const handlePointerDown = (e: React.PointerEvent) => { - e.preventDefault() - const target = e.currentTarget - target.setPointerCapture(e.pointerId) - - const styleElement = document.createElement('style') - styleElement.id = 'opacity-dragging-cursor' - styleElement.innerHTML = ` - body, body * { - cursor: ew-resize !important; - user-select: none !important; - } - ` - document.head.appendChild(styleElement) - - const startX = e.clientX - const startOpacity = opacity - setIsDragging(true) - - const handlePointerMove = (event: PointerEvent) => { - const deltaX = event.clientX - startX - const step = 0.5 - let newOpacity = Math.round(startOpacity + deltaX * step) - newOpacity = Math.max(0, Math.min(100, newOpacity)) - onChange(newOpacity) - } - - const handlePointerUp = (event: PointerEvent) => { - target.releasePointerCapture(event.pointerId) - setIsDragging(false) - - const styleToRemove = document.getElementById('opacity-dragging-cursor') - if (styleToRemove) { - styleToRemove.remove() - } - - target.removeEventListener('pointermove', handlePointerMove) - document.removeEventListener('pointerup', handlePointerUp) - } - - target.addEventListener('pointermove', handlePointerMove) - document.addEventListener('pointerup', handlePointerUp) - } - - return ( - - ) -} export const InputColorPicker = ({ title = 'Background Color', @@ -116,9 +28,13 @@ export const InputColorPicker = ({ pickerSize = 250, }: InputColorPickerProps) => { const [isPickerOpen, setIsPickerOpen] = useState(false) - const [isBackgroundHidden, setIsBackgroundHidden] = useState(false) const pickerRef = useRef(null) + const { isHidden, handleToggleHide, handleDelete } = useImageActions( + onHideBackground, + onDeleteBackground + ) + const { hex, color, @@ -133,7 +49,7 @@ export const InputColorPicker = ({ handleColorChange, } = useColorPickerState({ value, onChange, onOpacityChange }) - const { position, handleDragStart, resetPosition } = useDraggable() + const { resetPosition } = useDraggable() const handleTogglePicker = () => { if (!isPickerOpen) { @@ -160,31 +76,14 @@ export const InputColorPicker = ({
{colorType === 'color' ? (
- + { @@ -232,124 +131,56 @@ export const InputColorPicker = ({ className="ml-1 bg-transparent border-none h-auto focus-within:ring-0 flex-1" classNameInput={cn( 'bg-transparent px-2 font-mono text-xs', - THEME_CLASSES.light.text, - THEME_CLASSES.dark.text + INPUT_THEME_CLASSES.light.text, + INPUT_THEME_CLASSES.dark.text )} - // disabled={hex === 'Mixed'} />
) : (
- - +
)} -
- -
- - -
+
{isPickerOpen && ( -
setIsPickerOpen(false)} + inputRef={pickerRef} > -
handleDragStart(e, pickerRef.current)} - > - - {title} - - -
- -
- -
-
+ + )}
) diff --git a/src/input-hex-with-preview/input-hex-with-preview.tsx b/src/input-hex-with-preview/input-hex-with-preview.tsx index 57c608d..ba89908 100644 --- a/src/input-hex-with-preview/input-hex-with-preview.tsx +++ b/src/input-hex-with-preview/input-hex-with-preview.tsx @@ -1,7 +1,13 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useMemo } from 'react' import tc from 'tinycolor2' import { Input } from '../input' +import { DragButton } from '../shared/components/drag-button/drag-button' +import { InputContainer } from '../shared/components/input-container/input-container' +import { INPUT_THEME_CLASSES } from '../shared/constants/input-theme' +import { useDraggableInput } from '../shared/hooks/use-draggable-input' +import { useHexInput } from '../shared/hooks/use-hex-input' import { cn } from '../shared/utils/cn' +import { convertToHex8 } from '../shared/utils/color-utils' export interface InputHexWithPreviewProps extends Omit, 'onChange' | 'value'> { @@ -22,24 +28,6 @@ export interface InputHexWithPreviewProps showAlpha?: boolean } -const THEME_CLASSES = { - light: { - container: 'bg-white border-gray-300 focus-within:ring-blue-500', - input: 'text-gray-900', - icon: 'text-gray-600', - dragArea: 'bg-gray-100 hover:bg-gray-200', - preview: 'border-gray-300', - }, - dark: { - container: - 'dark:bg-gray-800 dark:border-gray-600 dark:focus-within:ring-blue-400', - input: 'dark:text-gray-100', - icon: 'dark:text-gray-300', - dragArea: 'dark:bg-gray-700 dark:hover:bg-gray-600', - preview: 'dark:border-gray-600', - }, -} as const - export const InputHexWithPreview = React.forwardRef< HTMLInputElement, InputHexWithPreviewProps @@ -62,90 +50,29 @@ export const InputHexWithPreview = React.forwardRef< }, ref ) => { - const [isEditing, setIsEditing] = useState(false) - - // Normalize input color through tinycolor2 - const color = tc(hexColor) - const hex = color.toHex() // 6 symbols without # - const alpha = color.getAlpha() // 0-1 - const alphaHex = Math.round(alpha * 255) - .toString(16) - .padStart(2, '0') - // Full value: if showAlpha and alpha < 1, add alpha - const hexFromProp = showAlpha && alpha < 1 ? hex + alphaHex : hex - - const [localHex, setLocalHex] = useState(hexFromProp) - const dragStartValue = useRef(0) - - useEffect(() => { - if (!isEditing) { - setLocalHex(hexFromProp) + // Используем хук для управления hex input + const { localHex, handleHexInput, setIsEditing, setLocalHex } = useHexInput( + { + hexColor, + onChange: handleChange, + showAlpha, } - }, [hexColor, isEditing, hexFromProp]) - - // Helper: converts 8-symbol hex to format with alpha - const convertToHex8 = (hex8: string) => { - const base = hex8.slice(0, 6) - const alphaHex = hex8.slice(6, 8) - const alphaDecimal = parseInt(alphaHex, 16) / 255 - return tc(`#${base}`).setAlpha(alphaDecimal).toHex8String().toUpperCase() - } - - const handleHexInput = (e: React.ChangeEvent) => { - const maxLen = showAlpha ? 8 : 6 - // Filter only hex symbols, truncate by length - const filtered = e.target.value - .replace(/[^0-9a-fA-F]/g, '') - .slice(0, maxLen) - .toUpperCase() - - setLocalHex(filtered) + ) - // Emit change only when full length - if (filtered.length === 6) { - handleChange(`#${filtered}`) - } else if (filtered.length === 8 && showAlpha) { - handleChange(convertToHex8(filtered)) - } - } + // Получаем hex для drag-логики + const color = tc(hexColor) + const hex = color.toHex() const handleIconClick = () => { onIconClick?.(localHex) } - const handlePointerDown = (e: React.PointerEvent) => { - if (disabled || isDisabledMouseEvent) return - e.preventDefault() - - onIconPointerDown?.(localHex) - - const target = e.currentTarget - target.setPointerCapture(e.pointerId) - - const styleElement = document.createElement('style') - styleElement.id = 'dragging-cursor-style' - styleElement.innerHTML = ` - body, body * { - cursor: ew-resize !important; - user-select: none !important; - } - ` - document.head.appendChild(styleElement) - - dragStartValue.current = parseInt(hex, 16) - const startX = e.clientX - setIsEditing(true) - - // Save initial alpha to preserve it during drag - const initialAlpha = - showAlpha && localHex.length >= 8 ? localHex.slice(6, 8) : '' - - const handlePointerMove = (event: PointerEvent) => { - const movementX = event.clientX - startX + // Используем хук для drag-to-change логики + const { handlePointerDown: handleDrag } = useDraggableInput({ + orientation: 'horizontal', + onDragChange: (delta, startValue) => { const step = 55000 - let newhexColorInt = Math.round( - dragStartValue.current + movementX * step - ) + let newhexColorInt = Math.round(startValue + delta * step) newhexColorInt = Math.max(0, Math.min(0xffffff, newhexColorInt)) // Drag changes only base color (6 symbols) @@ -154,7 +81,11 @@ export const InputHexWithPreview = React.forwardRef< .padStart(6, '0') .toUpperCase() - // Update local state with new base color + preserved alpha + // Save initial alpha to preserve it during drag + const initialAlpha = + showAlpha && localHex.length >= 8 ? localHex.slice(6, 8) : '' + + // Update local state immediately for visual feedback setLocalHex(newBaseHex + initialAlpha) // Emit with alpha if it exists, otherwise just base color @@ -163,23 +94,20 @@ export const InputHexWithPreview = React.forwardRef< } else { handleChange(`#${newBaseHex}`) } - } - - const handlePointerUp = (event: PointerEvent) => { - target.releasePointerCapture(event.pointerId) + }, + onDragStart: () => { + setIsEditing(true) + onIconPointerDown?.(localHex) + }, + onDragEnd: () => { setIsEditing(false) onIconPointerUp?.(localHex) + }, + disabled: disabled || isDisabledMouseEvent, + }) - const styleToRemove = document.getElementById('dragging-cursor-style') - if (styleToRemove) { - styleToRemove.remove() - } - target.removeEventListener('pointermove', handlePointerMove) - document.removeEventListener('pointerup', handlePointerUp) - } - - target.addEventListener('pointermove', handlePointerMove) - document.addEventListener('pointerup', handlePointerUp) + const handlePointerDown = (e: React.PointerEvent) => { + handleDrag(e, parseInt(hex, 16)) } // Color for preview with opacity @@ -190,8 +118,8 @@ export const InputHexWithPreview = React.forwardRef<
- + # @@ -242,8 +155,8 @@ export const InputHexWithPreview = React.forwardRef< type="text" className={cn( 'w-full rounded-none border-none bg-transparent text-left px-2 py-0 h-full text-sm', - THEME_CLASSES.light.input, - THEME_CLASSES.dark.input, + INPUT_THEME_CLASSES.light.input, + INPUT_THEME_CLASSES.dark.input, classNameInput )} value={localHex.toLocaleUpperCase()} @@ -255,7 +168,7 @@ export const InputHexWithPreview = React.forwardRef< ref={ref} {...props} /> -
+ ) } ) diff --git a/src/input-hex/input-hex.tsx b/src/input-hex/input-hex.tsx index 5c81e92..01b9dc5 100644 --- a/src/input-hex/input-hex.tsx +++ b/src/input-hex/input-hex.tsx @@ -1,7 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react' +import React from 'react' import tc from 'tinycolor2' import { Input } from '../input' +import { DragButton } from '../shared/components/drag-button/drag-button' +import { InputContainer } from '../shared/components/input-container/input-container' +import { INPUT_THEME_CLASSES } from '../shared/constants/input-theme' +import { useDraggableInput } from '../shared/hooks/use-draggable-input' +import { useHexInput } from '../shared/hooks/use-hex-input' import { cn } from '../shared/utils/cn' +import { convertToHex8 } from '../shared/utils/color-utils' export interface InputHexProps extends Omit, 'onChange' | 'value'> { @@ -21,22 +27,6 @@ export interface InputHexProps showAlpha?: boolean } -const THEME_CLASSES = { - light: { - container: 'bg-white border-gray-300 focus-within:ring-blue-500', - input: 'text-gray-900', - icon: 'text-gray-600', - dragArea: 'bg-gray-100 hover:bg-gray-200', - }, - dark: { - container: - 'dark:bg-gray-800 dark:border-gray-600 dark:focus-within:ring-blue-400', - input: 'dark:text-gray-100', - icon: 'dark:text-gray-300', - dragArea: 'dark:bg-gray-700 dark:hover:bg-gray-600', - }, -} as const - export const InputHex = React.forwardRef( ( { @@ -55,90 +45,29 @@ export const InputHex = React.forwardRef( }, ref ) => { - const [isEditing, setIsEditing] = useState(false) - - // Normalize input color through tinycolor2 - const color = tc(hexColor) - const hex = color.toHex() // 6 symbols without # - const alpha = color.getAlpha() // 0-1 - const alphaHex = Math.round(alpha * 255) - .toString(16) - .padStart(2, '0') - // Full value: if showAlpha and alpha < 1, add alpha - const hexFromProp = showAlpha && alpha < 1 ? hex + alphaHex : hex - - const [localHex, setLocalHex] = useState(hexFromProp) - const dragStartValue = useRef(0) - - useEffect(() => { - if (!isEditing) { - setLocalHex(hexFromProp) + // Используем хук для управления hex input + const { localHex, handleHexInput, setIsEditing, setLocalHex } = useHexInput( + { + hexColor, + onChange: handleChange, + showAlpha, } - }, [hexColor, isEditing, hexFromProp]) - - // Helper: converts 8-symbol hex to format with alpha - const convertToHex8 = (hex8: string) => { - const base = hex8.slice(0, 6) - const alphaHex = hex8.slice(6, 8) - const alphaDecimal = parseInt(alphaHex, 16) / 255 - return tc(`#${base}`).setAlpha(alphaDecimal).toHex8String().toUpperCase() - } - - const handleHexInput = (e: React.ChangeEvent) => { - const maxLen = showAlpha ? 8 : 6 - // Filter only hex symbols, truncate by length - const filtered = e.target.value - .replace(/[^0-9a-fA-F]/g, '') - .slice(0, maxLen) - .toUpperCase() - - setLocalHex(filtered) + ) - // Emit change only when full length - if (filtered.length === 6) { - handleChange(`#${filtered}`) - } else if (filtered.length === 8 && showAlpha) { - handleChange(convertToHex8(filtered)) - } - } + // Получаем hex для drag-логики + const color = tc(hexColor) + const hex = color.toHex() const handleIconClick = () => { onIconClick?.(localHex) } - const handlePointerDown = (e: React.PointerEvent) => { - if (disabled || isDisabledMouseEvent) return - e.preventDefault() - - onIconPointerDown?.(localHex) - - const target = e.currentTarget - target.setPointerCapture(e.pointerId) - - const styleElement = document.createElement('style') - styleElement.id = 'dragging-cursor-style' - styleElement.innerHTML = ` - body, body * { - cursor: ew-resize !important; - user-select: none !important; - } - ` - document.head.appendChild(styleElement) - - dragStartValue.current = parseInt(hex, 16) - const startX = e.clientX - setIsEditing(true) - - // Save initial alpha to preserve it during drag - const initialAlpha = - showAlpha && localHex.length >= 8 ? localHex.slice(6, 8) : '' - - const handlePointerMove = (event: PointerEvent) => { - const movementX = event.clientX - startX + // Используем хук для drag-to-change логики + const { handlePointerDown: handleDrag } = useDraggableInput({ + orientation: 'horizontal', + onDragChange: (delta, startValue) => { const step = 55000 - let newhexColorInt = Math.round( - dragStartValue.current + movementX * step - ) + let newhexColorInt = Math.round(startValue + delta * step) newhexColorInt = Math.max(0, Math.min(0xffffff, newhexColorInt)) // Drag changes only base color (6 symbols) @@ -147,7 +76,11 @@ export const InputHex = React.forwardRef( .padStart(6, '0') .toUpperCase() - // Update local state with new base color + preserved alpha + // Save initial alpha to preserve it during drag + const initialAlpha = + showAlpha && localHex.length >= 8 ? localHex.slice(6, 8) : '' + + // Update local state immediately for visual feedback setLocalHex(newBaseHex + initialAlpha) // Emit with alpha if it exists, otherwise just base color @@ -156,61 +89,46 @@ export const InputHex = React.forwardRef( } else { handleChange(`#${newBaseHex}`) } - } - - const handlePointerUp = (event: PointerEvent) => { - target.releasePointerCapture(event.pointerId) + }, + onDragStart: () => { + setIsEditing(true) + onIconPointerDown?.(localHex) + }, + onDragEnd: () => { setIsEditing(false) onIconPointerUp?.(localHex) + }, + disabled: disabled || isDisabledMouseEvent, + }) - const styleToRemove = document.getElementById('dragging-cursor-style') - if (styleToRemove) { - styleToRemove.remove() - } - target.removeEventListener('pointermove', handlePointerMove) - document.removeEventListener('pointerup', handlePointerUp) - } - - target.addEventListener('pointermove', handlePointerMove) - document.addEventListener('pointerup', handlePointerUp) + const handlePointerDown = (e: React.PointerEvent) => { + handleDrag(e, parseInt(hex, 16)) } return ( -
- + ( ref={ref} {...props} /> -
+ ) } ) diff --git a/src/input-image-select/components/image-picker-modal.tsx b/src/input-image-select/components/image-picker-modal.tsx new file mode 100644 index 0000000..ad1fecd --- /dev/null +++ b/src/input-image-select/components/image-picker-modal.tsx @@ -0,0 +1,489 @@ +import { RotateCw, Upload } from 'lucide-react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { InputSelectModal } from '../../shared/components/input-select-modal/input-select-modal' +import { cn } from '../../shared/utils/cn' + +const THEME_CLASSES = { + light: { + container: 'bg-white', + text: 'text-gray-900', + textMuted: 'text-gray-500', + button: 'bg-blue-600 hover:bg-blue-700', + overlay: 'bg-black/50', + placeholder: 'bg-gray-200 text-gray-500', + }, + dark: { + container: 'dark:bg-gray-800', + text: 'dark:text-gray-200', + textMuted: 'dark:text-gray-400', + button: 'dark:bg-blue-600 dark:hover:bg-blue-700', + overlay: 'dark:bg-black/60', + placeholder: 'dark:bg-gray-700 dark:text-gray-400', + }, +} as const + +export interface ImageFilters { + exposure: number + contrast: number + saturation: number + temperature: number + tint: number + highlights: number + shadows: number +} + +interface ImagePickerModalProps { + isOpen: boolean + isRotateButtonActive: boolean + onClose: () => void + title: string + imageUrl?: string + opacityImage?: number + imageHidden?: boolean + onImageChange?: (file: File | null) => void + filters?: ImageFilters + onFiltersChange?: (filters: ImageFilters) => void + rotation?: number + onRotationChange?: (rotation: number) => void + className?: string + classNameContainer?: string + classNameButton?: string + classNameOverlay?: string + classNamePlaceholder?: string + classNameModal?: string + classNameModalHeader?: string + classNameModalTitle?: string + classNameModalCloseButton?: string + classNameModalContent?: string +} + +export const ImagePickerModal = ({ + isOpen, + isRotateButtonActive, + onClose, + title, + imageUrl, + opacityImage = 100, + imageHidden, + onImageChange, + filters, + onFiltersChange, + rotation = 0, + onRotationChange, + className, + classNameContainer, + classNameButton, + classNameOverlay, + classNamePlaceholder, + classNameModal, + classNameModalHeader, + classNameModalTitle, + classNameModalCloseButton, + classNameModalContent, +}: ImagePickerModalProps) => { + const [previewUrl, setPreviewUrl] = useState(imageUrl) + const [localFilters, setLocalFilters] = useState( + filters || { + exposure: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + highlights: 0, + shadows: 0, + } + ) + const fileInputRef = useRef(null) + const modalRef = useRef(null) + + const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max) + const [rotationAngle, setRotationAngle] = useState(rotation) + + useEffect(() => { + setPreviewUrl(imageUrl) + }, [imageUrl]) + + useEffect(() => { + if (filters) { + setLocalFilters(filters) + } + }, [filters]) + + useEffect(() => { + setRotationAngle(rotation) + }, [rotation]) + + const handleFilterChange = (key: keyof ImageFilters, value: number) => { + const newFilters = { ...localFilters, [key]: value } + setLocalFilters(newFilters) + onFiltersChange?.(newFilters) + } + + const handleRotate = () => { + const nextRotation = (rotationAngle + 90) % 360 + setRotationAngle(nextRotation) + onRotationChange?.(nextRotation) + } + + const getFilterStyle = () => { + const highlightsBoost = localFilters.highlights / 100 + const shadowsBoost = localFilters.shadows / 100 + + const brightness = clamp( + 1 + + localFilters.exposure / 100 + + Math.max(highlightsBoost, 0) * 0.4 - + Math.max(shadowsBoost, 0) * 0.5 + + Math.max(-shadowsBoost, 0) * 0.3 - + Math.max(-highlightsBoost, 0) * 0.3, + 0.1, + 3 + ) + const contrast = clamp( + 1 + + localFilters.contrast / 100 + + Math.max(highlightsBoost, 0) * 0.3 + + Math.max(shadowsBoost, 0) * 0.4 - + Math.max(-highlightsBoost, 0) * 0.3 - + Math.max(-shadowsBoost, 0) * 0.4, + 0.1, + 4 + ) + const saturate = clamp(1 + localFilters.saturation / 100, 0, 4) + const hueRotate = localFilters.temperature * 0.6 + + return { + filter: `brightness(${brightness}) contrast(${contrast}) saturate(${saturate}) hue-rotate(${hueRotate}deg)`, + } + } + + const getTintOverlayStyle = (): CSSProperties | null => { + if (!localFilters.tint) return null + const intensity = Math.min(Math.abs(localFilters.tint) / 100, 1) + const hue = localFilters.tint >= 0 ? 120 : 300 + return { + backgroundColor: `hsla(${hue}, 100%, 50%, ${intensity * 0.5})`, + mixBlendMode: 'color', + opacity: intensity * 0.6, + } + } + + const getHighlightsOverlayStyle = (): CSSProperties | null => { + if (!localFilters.highlights) return null + const value = localFilters.highlights + const intensity = Math.min(Math.abs(value) / 100, 1) + if (value > 0) { + return { + backgroundColor: 'rgba(255, 255, 255, 1)', + mixBlendMode: 'screen', + opacity: intensity * 0.5, + } + } + return { + backgroundColor: 'rgba(0, 0, 0, 1)', + mixBlendMode: 'multiply', + opacity: intensity * 0.4, + } + } + + const getShadowsOverlayStyle = (): CSSProperties | null => { + if (!localFilters.shadows) return null + const value = localFilters.shadows + const intensity = Math.min(Math.abs(value) / 100, 1) + if (value > 0) { + return { + backgroundColor: 'rgba(0, 0, 0, 1)', + mixBlendMode: 'multiply', + opacity: intensity * 0.6, + } + } + return { + backgroundColor: 'rgba(255, 255, 255, 1)', + mixBlendMode: 'screen', + opacity: intensity * 0.4, + } + } + + const tintOverlayStyle = getTintOverlayStyle() + const highlightsOverlayStyle = getHighlightsOverlayStyle() + const shadowsOverlayStyle = getShadowsOverlayStyle() + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + setPreviewUrl(reader.result as string) + } + reader.readAsDataURL(file) + onImageChange?.(file) + } + } + + const handleUploadClick = () => { + fileInputRef.current?.click() + } + + if (!isOpen) return null + + return ( + +
+
+ {isRotateButtonActive && ( + + )} +
+
+ {previewUrl && ( + <> + {imageHidden ? ( +
+ Image Hidden +
+ ) : ( + <> +
+ Preview + {tintOverlayStyle && ( +
+ )} + {highlightsOverlayStyle && ( +
+ )} + {shadowsOverlayStyle && ( +
+ )} +
+
+ +
+ + )} + + )} + {!previewUrl && ( +
+ +
+ )} +
+
+ {previewUrl && !imageHidden && ( + + )} + + ) +} + +const ImageFiltersPanel = ({ + filters, + onFilterChange, +}: { + filters: ImageFilters + onFilterChange: (key: keyof ImageFilters, value: number) => void +}) => { + const filterControls = [ + { + key: 'exposure' as keyof ImageFilters, + label: 'Exposure', + min: -100, + max: 100, + }, + { + key: 'contrast' as keyof ImageFilters, + label: 'Contrast', + min: -100, + max: 100, + }, + { + key: 'saturation' as keyof ImageFilters, + label: 'Saturation', + min: -100, + max: 100, + }, + { + key: 'temperature' as keyof ImageFilters, + label: 'Temperature', + min: -100, + max: 100, + }, + { key: 'tint' as keyof ImageFilters, label: 'Tint', min: -100, max: 100 }, + { + key: 'highlights' as keyof ImageFilters, + label: 'Highlights', + min: -100, + max: 100, + }, + { + key: 'shadows' as keyof ImageFilters, + label: 'Shadows', + min: -100, + max: 100, + }, + ] + + const handleSliderChange = ( + key: keyof ImageFilters, + rawValue: number, + snapThreshold = 5 + ) => { + const snappedValue = Math.abs(rawValue) <= snapThreshold ? 0 : rawValue + onFilterChange(key, snappedValue) + } + + return ( +
+ {filterControls.map(({ key, label, min, max }) => ( +
+ + handleSliderChange(key, Number(e.target.value))} + className="flex-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer slider" + style={{ + background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${ + ((filters[key] - min) / (max - min)) * 100 + }%, #d1d5db ${((filters[key] - min) / (max - min)) * 100}%, #d1d5db 100%)`, + }} + /> +
+ ))} +
+ ) +} + +const UploadImageButton = ({ + handleUploadClick, + fileInputRef, + handleFileChange, + classNameButton, +}: { + handleUploadClick: () => void + fileInputRef: React.RefObject + handleFileChange: (e: React.ChangeEvent) => void + classNameButton?: string +}) => { + return ( + <> + + + + ) +} diff --git a/src/input-image-select/index.tsx b/src/input-image-select/index.tsx new file mode 100644 index 0000000..3ccb85d --- /dev/null +++ b/src/input-image-select/index.tsx @@ -0,0 +1,3 @@ +export type { ImageFilters } from './components/image-picker-modal' +export { InputImageSelect } from './input-image-select' +export type { InputImageSelectProps } from './input-image-select' diff --git a/src/input-image-select/input-image-select.tsx b/src/input-image-select/input-image-select.tsx new file mode 100644 index 0000000..08d4bf5 --- /dev/null +++ b/src/input-image-select/input-image-select.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react' +import { ActionButtons } from '../shared/components/action-buttons/action-buttons' +import { + IMAGE_INPUT_THEME_CLASSES, + useImageActions, + useImageState, +} from '../shared/components/image-input' +import { ImagePreview } from '../shared/components/image-preview/image-preview' +import { TextButton } from '../shared/components/text-button/text-button' +import { cn } from '../shared/utils/cn' +import { + ImagePickerModal, + type ImageFilters, +} from './components/image-picker-modal' + +export interface InputImageSelectProps { + title?: string + className?: string + imageUrl?: string + opacity?: number + onOpacityChange?: (opacity: number) => void + onHideImage?: (hidden: boolean) => void + onDeleteImage?: () => void + onImageChange?: (file: File | null) => void + filters?: ImageFilters + onFiltersChange?: (filters: ImageFilters) => void + rotation?: number + onRotationChange?: (rotation: number) => void + classNameModal?: string + classNameModalContainer?: string + classNameModalButton?: string + classNameModalOverlay?: string + classNameModalPlaceholder?: string + classNameModalWrapper?: string + classNameModalHeader?: string + classNameModalTitle?: string + classNameModalCloseButton?: string + classNameModalContent?: string +} + +export const InputImageSelect = ({ + title = 'Image', + className, + imageUrl, + opacity = 100, + onImageChange, + onOpacityChange, + onHideImage, + onDeleteImage, + filters, + onFiltersChange, + rotation = 0, + onRotationChange, + classNameModal, + classNameModalContainer, + classNameModalButton, + classNameModalOverlay, + classNameModalPlaceholder, + classNameModalWrapper, + classNameModalHeader, + classNameModalTitle, + classNameModalCloseButton, + classNameModalContent, +}: InputImageSelectProps) => { + // Используем новые хуки из общей инфраструктуры + const [isPickerOpen, setIsPickerOpen] = useState(false) + + const { + opacity: opacityValue, + rotation: rotationValue, + setOpacity: setOpacityValue, + setRotation: setRotationValue, + } = useImageState(imageUrl, opacity, rotation) + + const { + isHidden, + isRotateButtonActive, + handleToggleHide, + handleDelete, + setIsRotateButtonActive, + } = useImageActions(onHideImage, onDeleteImage) + + useEffect(() => { + if (imageUrl) { + setIsRotateButtonActive(true) + } + }, [imageUrl, setIsRotateButtonActive]) + + const handleTogglePicker = () => { + setIsPickerOpen(prev => !prev) + } + + const handleOpacityChange = (newOpacity: number) => { + setOpacityValue(newOpacity) + onOpacityChange?.(newOpacity) + } + + const handleRotationChange = (nextRotation: number) => { + setRotationValue(nextRotation) + onRotationChange?.(nextRotation) + } + + return ( +
+
+
+ + + Image + +
+ + +
+ + setIsPickerOpen(false)} + title={title} + imageUrl={imageUrl} + opacityImage={opacityValue} + imageHidden={isHidden} + onImageChange={onImageChange} + filters={filters} + onFiltersChange={onFiltersChange} + rotation={rotationValue} + onRotationChange={handleRotationChange} + className={classNameModalWrapper} + classNameContainer={classNameModalContainer} + classNameButton={classNameModalButton} + classNameOverlay={classNameModalOverlay} + classNamePlaceholder={classNameModalPlaceholder} + classNameModal={classNameModal} + classNameModalHeader={classNameModalHeader} + classNameModalTitle={classNameModalTitle} + classNameModalCloseButton={classNameModalCloseButton} + classNameModalContent={classNameModalContent} + /> +
+ ) +} diff --git a/src/input-image-select/utils/index.ts b/src/input-image-select/utils/index.ts new file mode 100644 index 0000000..2050266 --- /dev/null +++ b/src/input-image-select/utils/index.ts @@ -0,0 +1 @@ +export { rotateImageOnCanvas, rotateImage90Degrees } from './rotate-image' diff --git a/src/input-image-select/utils/rotate-image.ts b/src/input-image-select/utils/rotate-image.ts new file mode 100644 index 0000000..024d307 --- /dev/null +++ b/src/input-image-select/utils/rotate-image.ts @@ -0,0 +1,131 @@ +/** + * Rotates an image on canvas and returns the rotated image as a data URL. + * + * @param imageUrl - The source image URL (can be data URL or regular URL) + * @param rotation - Rotation angle in degrees (0, 90, 180, 270, etc.) + * @param quality - JPEG quality (0-1), default 0.95 + * @returns Promise that resolves to the rotated image data URL + */ +export const rotateImageOnCanvas = async ( + imageUrl: string, + rotation: number, + quality: number = 0.95 +): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + try { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + if (!ctx) { + reject(new Error('Failed to get canvas context')) + return + } + + // Normalize rotation to 0-360 range + const normalizedRotation = ((rotation % 360) + 360) % 360 + + // Calculate canvas dimensions based on rotation + const radians = (normalizedRotation * Math.PI) / 180 + const cos = Math.abs(Math.cos(radians)) + const sin = Math.abs(Math.sin(radians)) + + const newWidth = img.width * cos + img.height * sin + const newHeight = img.width * sin + img.height * cos + + canvas.width = newWidth + canvas.height = newHeight + + // Move to center of canvas + ctx.translate(newWidth / 2, newHeight / 2) + + // Rotate + ctx.rotate(radians) + + // Draw image centered + ctx.drawImage(img, -img.width / 2, -img.height / 2) + + // Convert to data URL + const dataUrl = canvas.toDataURL('image/jpeg', quality) + resolve(dataUrl) + } catch (error) { + reject(error) + } + } + + img.onerror = () => { + reject(new Error('Failed to load image')) + } + + img.src = imageUrl + }) +} + +/** + * Rotates an image by 90-degree increments only (optimized for common use case). + * This version maintains the exact dimensions for 90/270 rotations. + * + * @param imageUrl - The source image URL + * @param rotation - Rotation angle (should be 0, 90, 180, or 270) + * @param quality - JPEG quality (0-1), default 0.95 + * @returns Promise that resolves to the rotated image data URL + */ +export const rotateImage90Degrees = async ( + imageUrl: string, + rotation: number, + quality: number = 0.95 +): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + try { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + if (!ctx) { + reject(new Error('Failed to get canvas context')) + return + } + + // Normalize rotation to 0, 90, 180, or 270 + const normalizedRotation = ((rotation % 360) + 360) % 360 + const steps = Math.round(normalizedRotation / 90) % 4 + + // Set canvas dimensions based on rotation + if (steps === 1 || steps === 3) { + // 90 or 270 degrees - swap width and height + canvas.width = img.height + canvas.height = img.width + } else { + // 0 or 180 degrees - keep original dimensions + canvas.width = img.width + canvas.height = img.height + } + + // Move to center and rotate + ctx.translate(canvas.width / 2, canvas.height / 2) + ctx.rotate((steps * Math.PI) / 2) + + // Draw image centered + ctx.drawImage(img, -img.width / 2, -img.height / 2) + + // Convert to data URL + const dataUrl = canvas.toDataURL('image/jpeg', quality) + resolve(dataUrl) + } catch (error) { + reject(error) + } + } + + img.onerror = () => { + reject(new Error('Failed to load image')) + } + + img.src = imageUrl + }) +} diff --git a/src/input-number-select/input-number-select.tsx b/src/input-number-select/input-number-select.tsx index f101a6a..2dbdc39 100644 --- a/src/input-number-select/input-number-select.tsx +++ b/src/input-number-select/input-number-select.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { FaRegDotCircle } from 'react-icons/fa' import { Input } from '../input' +import { INPUT_THEME_CLASSES } from '../shared/constants/input-theme' import { cn } from '../shared/utils/cn' import { removeTrailingZeros } from '../shared/utils/remove-trailing-zeros' @@ -37,21 +38,6 @@ export interface InputNumberSelectProps isDisabledMouseEvent?: boolean } -const THEME_CLASSES = { - light: { - container: 'bg-white border-gray-300 focus-within:ring-blue-500', - input: 'text-gray-900', - icon: 'text-gray-600', - dragArea: 'bg-gray-100 hover:bg-gray-200', - }, - dark: { - container: - 'dark:bg-gray-800 dark:border-gray-600 dark:focus-within:ring-blue-400', - input: 'dark:text-gray-100', - icon: 'dark:text-gray-300', - dragArea: 'dark:bg-gray-700 dark:hover:bg-gray-600', - }, -} as const export const InputNumberSelect = React.forwardRef< HTMLInputElement, @@ -342,10 +328,10 @@ export const InputNumberSelect = React.forwardRef< return ( {icon} @@ -353,7 +339,7 @@ export const InputNumberSelect = React.forwardRef< } return ( ) }, [icon]) @@ -362,8 +348,8 @@ export const InputNumberSelect = React.forwardRef<
{unit} diff --git a/src/input-upload-image/index.tsx b/src/input-upload-image/index.tsx new file mode 100644 index 0000000..2387b5f --- /dev/null +++ b/src/input-upload-image/index.tsx @@ -0,0 +1,2 @@ +export { InputUploadImage } from './input-upload-image' +export type { InputUploadImageProps } from './input-upload-image' diff --git a/src/input-upload-image/input-upload-image.tsx b/src/input-upload-image/input-upload-image.tsx new file mode 100644 index 0000000..57afc4e --- /dev/null +++ b/src/input-upload-image/input-upload-image.tsx @@ -0,0 +1,96 @@ +import { ActionButtons } from '../shared/components/action-buttons/action-buttons' +import { ImagePreview } from '../shared/components/image-preview/image-preview' +import { TextButton } from '../shared/components/text-button/text-button' +import { + IMAGE_INPUT_THEME_CLASSES, + useFileUpload, + useImageActions, + getFileNameDisplay, +} from '../shared/components/image-input' +import { cn } from '../shared/utils/cn' + +export interface InputUploadImageProps { + title?: string + className?: string + imageUrl?: string + fileName?: string + onImageChange?: (file: File | null) => void + onHideImage?: (hidden: boolean) => void + onDeleteImage?: () => void +} + +export const InputUploadImage = ({ + title = 'Upload image', + className, + imageUrl, + fileName, + onImageChange, + onHideImage, + onDeleteImage, +}: InputUploadImageProps) => { + // Используем новые хуки из общей инфраструктуры + const { + previewUrl, + fileName: currentFileName, + fileInputRef, + handleFileChange, + handleClick, + clearFile, + } = useFileUpload(onImageChange, imageUrl, fileName) + + const { isHidden, handleToggleHide, handleDelete } = useImageActions( + onHideImage, + onDeleteImage, + clearFile + ) + + const displayFileName = getFileNameDisplay( + currentFileName, + fileName, + imageUrl, + title + ) + + return ( +
+
+
+ + + {displayFileName} + +
+ + +
+ + +
+ ) +} diff --git a/src/input/input.tsx b/src/input/input.tsx index 7db8cfc..ec8b201 100644 --- a/src/input/input.tsx +++ b/src/input/input.tsx @@ -1,21 +1,20 @@ import * as React from 'react' import { cn } from '../shared/utils/cn' -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - - ) - } -) +export const Input = React.forwardRef< + HTMLInputElement, + React.ComponentProps<'input'> +>(({ className, type, ...props }, ref) => { + return ( + + ) +}) Input.displayName = 'Input' - -export { Input } diff --git a/src/shared/components/action-buttons/action-buttons.tsx b/src/shared/components/action-buttons/action-buttons.tsx new file mode 100644 index 0000000..7e89dc9 --- /dev/null +++ b/src/shared/components/action-buttons/action-buttons.tsx @@ -0,0 +1,90 @@ +import { Eye, EyeOff, Trash2 } from 'lucide-react' +import React from 'react' +import { cn } from '../../utils/cn' +import { Divider } from '../divider/divider' +import { IconButton } from '../icon-button/icon-button' +import { OpacityDragControl } from '../opacity-drag-control/opacity-drag-control' + +export interface ActionButtonsProps { + /** + * Показывать кнопку видимости + */ + showVisibility?: boolean + /** + * Показывать кнопку удаления + */ + showDelete?: boolean + /** + * Показывать opacity control + */ + showOpacity?: boolean + /** + * Состояние видимости (для кнопки Eye/EyeOff) + */ + isHidden?: boolean + /** + * Значение opacity (0-100) + */ + opacity?: number + /** + * Обработчик изменения видимости + */ + onToggleVisibility?: () => void + /** + * Обработчик удаления + */ + onDelete?: () => void + /** + * Обработчик изменения opacity + */ + onOpacityChange?: (opacity: number) => void + /** + * Дополнительные классы для divider + */ + dividerClassName?: string + /** + * Дополнительные классы для контейнера + */ + className?: string +} + +/** + * Группа action buttons для input компонентов + * Включает: OpacityDragControl, Eye/EyeOff, Trash + */ +export const ActionButtons: React.FC = ({ + showVisibility = true, + showDelete = true, + showOpacity = true, + isHidden = false, + opacity = 100, + onToggleVisibility, + onDelete, + onOpacityChange, + dividerClassName, + className, +}) => { + return ( +
+ {showOpacity && onOpacityChange && ( + + )} + + {(showOpacity || showVisibility || showDelete) && ( + + )} + + {showVisibility && onToggleVisibility && ( + : } + variant="muted" + /> + )} + + {showDelete && onDelete && ( + } variant="muted" /> + )} +
+ ) +} diff --git a/src/shared/components/color-preview/color-preview.tsx b/src/shared/components/color-preview/color-preview.tsx new file mode 100644 index 0000000..a0aebfb --- /dev/null +++ b/src/shared/components/color-preview/color-preview.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { cn } from '../../utils/cn' + +export interface ColorPreviewProps { + /** + * Цвет для отображения (любой валидный CSS color) + */ + color: string + /** + * Показывать checkerboard фон для прозрачности + */ + showCheckerboard?: boolean + /** + * Размер preview (по умолчанию 'size-5') + */ + size?: 'size-4' | 'size-5' | 'size-6' | 'size-8' + /** + * Дополнительные классы + */ + className?: string + /** + * Обработчик клика + */ + onClick?: () => void + /** + * Disabled состояние + */ + disabled?: boolean +} + +const CHECKERBOARD_PATTERN = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3E%3Cg fill='%23d1d1d1'%3E%3Cpath fill-rule='evenodd' d='M0 0h4v4H0V0zm4 4h4v4H4V4z'/%3E%3C/g%3E%3C/svg%3E\")" + +/** + * Компонент для отображения цветового preview с опциональным checkerboard фоном + */ +export const ColorPreview = React.forwardRef< + HTMLButtonElement, + ColorPreviewProps +>( + ( + { + color, + showCheckerboard = true, + size = 'size-5', + className, + onClick, + disabled = false, + }, + ref + ) => { + const content = ( + <> + {/* Checkerboard background для прозрачности */} + {showCheckerboard && ( +
+ )} + + {/* Color overlay */} +
+ + ) + + const baseClasses = cn( + 'relative rounded-sm overflow-hidden bg-white flex-shrink-0', + size, + className + ) + + if (onClick) { + return ( + + ) + } + + return
{content}
+ } +) + +ColorPreview.displayName = 'ColorPreview' diff --git a/src/shared/components/divider/divider.tsx b/src/shared/components/divider/divider.tsx new file mode 100644 index 0000000..a0e785b --- /dev/null +++ b/src/shared/components/divider/divider.tsx @@ -0,0 +1,5 @@ +import { cn } from '../../utils/cn' + +export const Divider = ({ className }: { className?: string }) => { + return
+} diff --git a/src/shared/components/drag-button/drag-button.tsx b/src/shared/components/drag-button/drag-button.tsx new file mode 100644 index 0000000..59afcbb --- /dev/null +++ b/src/shared/components/drag-button/drag-button.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { cn } from '../../utils/cn' +import { INPUT_THEME_CLASSES } from '../../constants/input-theme' + +export interface DragButtonProps + extends Omit, 'children'> { + /** + * Содержимое кнопки (иконка, текст, элемент) + */ + children: React.ReactNode + /** + * Дополнительные классы + */ + className?: string + /** + * Disabled состояние для mouse events + */ + isDisabledMouseEvent?: boolean + /** + * Ориентация курсора при drag + */ + dragOrientation?: 'horizontal' | 'vertical' +} + +/** + * Кнопка с drag-to-change функциональностью + * Используется в InputHex, InputHexWithPreview, InputNumberSelect + */ +export const DragButton = React.forwardRef( + ( + { + children, + className, + disabled = false, + isDisabledMouseEvent = false, + dragOrientation = 'horizontal', + ...props + }, + ref + ) => { + const cursorClass = + dragOrientation === 'horizontal' ? 'cursor-ew-resize' : 'cursor-ns-resize' + + return ( + + ) + } +) + +DragButton.displayName = 'DragButton' diff --git a/src/shared/components/icon-button/icon-button.tsx b/src/shared/components/icon-button/icon-button.tsx new file mode 100644 index 0000000..44417ed --- /dev/null +++ b/src/shared/components/icon-button/icon-button.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { cn } from '../../utils/cn' + +export interface IconButtonProps + extends Omit, 'children'> { + /** + * Иконка для отображения (React element или компонент) + */ + icon: React.ReactNode + /** + * Размер иконки (по умолчанию 14) + */ + iconSize?: number + /** + * Вариант стиля + */ + variant?: 'default' | 'muted' | 'danger' + /** + * Дополнительные классы + */ + className?: string +} + +/** + * Компонент кнопки с иконкой для action buttons + */ +export const IconButton = React.forwardRef( + ( + { + icon, + iconSize = 14, + variant = 'muted', + className, + disabled = false, + ...props + }, + ref + ) => { + // Клонируем иконку с нужным размером, если это React element + const iconElement = React.isValidElement(icon) + ? React.cloneElement(icon as React.ReactElement<{ size?: number }>, { + size: iconSize, + }) + : icon + + return ( + + ) + } +) + +IconButton.displayName = 'IconButton' diff --git a/src/shared/components/image-input/constants/theme.ts b/src/shared/components/image-input/constants/theme.ts new file mode 100644 index 0000000..5ad0da4 --- /dev/null +++ b/src/shared/components/image-input/constants/theme.ts @@ -0,0 +1,23 @@ +/** + * Общие theme классы для image input компонентов + * Используется в InputImageSelect, InputUploadImage и связанных компонентах + */ +export const IMAGE_INPUT_THEME_CLASSES = { + light: { + container: 'bg-white border-gray-300 focus-within:ring-blue-500', + text: 'text-gray-900', + textMuted: 'text-gray-600', + icon: 'text-gray-600', + preview: 'border-gray-300', + divider: 'bg-gray-300', + }, + dark: { + container: + 'dark:bg-gray-800 dark:border-gray-600 dark:focus-within:ring-blue-400', + text: 'dark:text-gray-200', + textMuted: 'dark:text-gray-400', + icon: 'dark:text-gray-300', + preview: 'dark:border-gray-600', + divider: 'dark:bg-gray-600', + }, +} as const diff --git a/src/shared/components/image-input/hooks/use-file-upload.ts b/src/shared/components/image-input/hooks/use-file-upload.ts new file mode 100644 index 0000000..1c5d659 --- /dev/null +++ b/src/shared/components/image-input/hooks/use-file-upload.ts @@ -0,0 +1,61 @@ +import { useRef, useState } from 'react' +import type { UseFileUploadReturn } from '../types' +import { readFileAsDataURL } from '../utils/file-reader' + +/** + * Хук для управления загрузкой файлов + * Обрабатывает выбор файла, чтение и preview + * + * @param onImageChange - Callback при изменении файла + * @param initialImageUrl - Начальный URL изображения + * @param initialFileName - Начальное имя файла + * @returns Объект с состоянием и методами для работы с файлами + */ +export const useFileUpload = ( + onImageChange?: (file: File | null) => void, + initialImageUrl?: string, + initialFileName?: string +): UseFileUploadReturn => { + const fileInputRef = useRef(null) + const [previewUrl, setPreviewUrl] = useState( + initialImageUrl + ) + const [fileName, setFileName] = useState(initialFileName) + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + + if (file) { + try { + const dataUrl = await readFileAsDataURL(file) + setPreviewUrl(dataUrl) + setFileName(file.name) + onImageChange?.(file) + } catch (error) { + console.error('Error reading file:', error) + onImageChange?.(null) + } + } + } + + const handleClick = () => { + fileInputRef.current?.click() + } + + const clearFile = () => { + setPreviewUrl(undefined) + setFileName(undefined) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + return { + previewUrl, + fileName, + fileInputRef, + handleFileChange, + handleClick, + clearFile, + } +} diff --git a/src/shared/components/image-input/hooks/use-image-actions.ts b/src/shared/components/image-input/hooks/use-image-actions.ts new file mode 100644 index 0000000..77e4f28 --- /dev/null +++ b/src/shared/components/image-input/hooks/use-image-actions.ts @@ -0,0 +1,48 @@ +import { useState } from 'react' +import type { UseImageActionsReturn } from '../types' + +/** + * Хук для управления действиями с изображением (hide/delete) + * Управляет состоянием видимости и обрабатывает удаление + * + * @param onHideImage - Callback при скрытии/показе изображения + * @param onDeleteImage - Callback при удалении изображения + * @param onClearState - Дополнительный callback для очистки состояния (опционально) + * @returns Объект с состоянием и обработчиками действий + */ +export const useImageActions = ( + onHideImage?: (hidden: boolean) => void, + onDeleteImage?: () => void, + onClearState?: () => void +): UseImageActionsReturn => { + const [isHidden, setIsHidden] = useState(false) + const [isRotateButtonActive, setIsRotateButtonActive] = useState(true) + + + const handleToggleHide = () => { + const newHiddenState = !isHidden + const newRotateButtonState = !isRotateButtonActive + setIsHidden(newHiddenState) + onHideImage?.(newHiddenState) + setIsRotateButtonActive(newRotateButtonState) + } + + const handleDelete = () => { + // Сбрасываем состояние + setIsHidden(false) + setIsRotateButtonActive(false) + // Вызываем дополнительную очистку состояния (если передана) + onClearState?.() + + // Вызываем внешний callback + onDeleteImage?.() + } + + return { + isHidden, + handleToggleHide, + handleDelete, + isRotateButtonActive, + setIsRotateButtonActive, + } +} diff --git a/src/shared/components/image-input/hooks/use-image-state.ts b/src/shared/components/image-input/hooks/use-image-state.ts new file mode 100644 index 0000000..f734efa --- /dev/null +++ b/src/shared/components/image-input/hooks/use-image-state.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' +import type { UseImageStateReturn } from '../types' + +/** + * Хук для управления состоянием изображения + * Синхронизирует внутреннее состояние с внешними пропсами + * + * @param initialImageUrl - Начальный URL изображения + * @param initialOpacity - Начальная прозрачность (0-100) + * @param initialRotation - Начальный угол поворота (0-360) + * @returns Объект с состоянием и методами управления + */ +export const useImageState = ( + initialImageUrl?: string, + initialOpacity: number = 100, + initialRotation: number = 0 +): UseImageStateReturn => { + const [imageUrl, setImageUrl] = useState(initialImageUrl) + const [isHidden, setIsHidden] = useState(false) + const [opacity, setOpacity] = useState(initialOpacity) + const [rotation, setRotation] = useState(initialRotation) + + // Синхронизация с внешними пропсами + useEffect(() => { + setImageUrl(initialImageUrl) + }, [initialImageUrl]) + + useEffect(() => { + setOpacity(initialOpacity) + }, [initialOpacity]) + + useEffect(() => { + setRotation(initialRotation) + }, [initialRotation]) + + const toggleHidden = () => { + setIsHidden(prev => !prev) + } + + return { + imageUrl, + isHidden, + opacity, + rotation, + setImageUrl, + setIsHidden, + setOpacity, + setRotation, + toggleHidden, + } +} diff --git a/src/shared/components/image-input/index.ts b/src/shared/components/image-input/index.ts new file mode 100644 index 0000000..6c673cc --- /dev/null +++ b/src/shared/components/image-input/index.ts @@ -0,0 +1,31 @@ +/** + * Общая инфраструктура для image input компонентов + * Экспорт констант, типов, хуков и утилит + */ + +// Constants +export { IMAGE_INPUT_THEME_CLASSES } from './constants/theme' + +// Types +export type { + BaseImageInputProps, + ImageInputWithOpacityProps, + ImageInputWithFileNameProps, + ImageState, + UseImageStateReturn, + UseFileUploadReturn, + UseImageActionsReturn, +} from './types' + +// Hooks +export { useImageState } from './hooks/use-image-state' +export { useFileUpload } from './hooks/use-file-upload' +export { useImageActions } from './hooks/use-image-actions' + +// Utils +export { + readFileAsDataURL, + isImageFile, + getFileExtension, +} from './utils/file-reader' +export { getFileNameDisplay, truncateFileName } from './utils/file-name' diff --git a/src/shared/components/image-input/types/index.ts b/src/shared/components/image-input/types/index.ts new file mode 100644 index 0000000..03b0cb2 --- /dev/null +++ b/src/shared/components/image-input/types/index.ts @@ -0,0 +1,78 @@ +/** + * Базовые типы и интерфейсы для image input компонентов + */ + +/** + * Базовые пропсы для всех image input компонентов + */ +export interface BaseImageInputProps { + title?: string + className?: string + imageUrl?: string + onImageChange?: (file: File | null) => void + onHideImage?: (hidden: boolean) => void + onDeleteImage?: () => void +} + +/** + * Пропсы для компонентов с opacity контролом + */ +export interface ImageInputWithOpacityProps extends BaseImageInputProps { + opacity?: number + onOpacityChange?: (opacity: number) => void +} + +/** + * Пропсы для компонентов с отображением имени файла + */ +export interface ImageInputWithFileNameProps extends BaseImageInputProps { + fileName?: string +} + +/** + * Состояние изображения + */ +export interface ImageState { + url?: string + isHidden: boolean + opacity: number + rotation: number +} + +/** + * Результат хука useImageState + */ +export interface UseImageStateReturn { + imageUrl?: string + isHidden: boolean + opacity: number + rotation: number + setImageUrl: (url?: string) => void + setIsHidden: (hidden: boolean) => void + setOpacity: (opacity: number) => void + setRotation: (rotation: number) => void + toggleHidden: () => void +} + +/** + * Результат хука useFileUpload + */ +export interface UseFileUploadReturn { + previewUrl?: string + fileName?: string + fileInputRef: React.RefObject + handleFileChange: (e: React.ChangeEvent) => void + handleClick: () => void + clearFile: () => void +} + +/** + * Результат хука useImageActions + */ +export interface UseImageActionsReturn { + isHidden: boolean + handleToggleHide: () => void + handleDelete: () => void + isRotateButtonActive: boolean + setIsRotateButtonActive: (active: boolean) => void +} diff --git a/src/shared/components/image-input/utils/file-name.ts b/src/shared/components/image-input/utils/file-name.ts new file mode 100644 index 0000000..4024ad9 --- /dev/null +++ b/src/shared/components/image-input/utils/file-name.ts @@ -0,0 +1,50 @@ +/** + * Утилиты для работы с именами файлов + */ + +/** + * Определяет, какое имя файла отображать + * @param currentFileName - Текущее имя файла (из состояния) + * @param initialFileName - Начальное имя файла (из пропсов) + * @param imageUrl - URL изображения + * @param fallbackTitle - Fallback текст, если нет файла + * @returns Строка для отображения + */ +export const getFileNameDisplay = ( + currentFileName?: string, + initialFileName?: string, + imageUrl?: string, + fallbackTitle: string = 'Upload image' +): string => { + if (currentFileName) { + return currentFileName + } + if (imageUrl && initialFileName) { + return initialFileName + } + return fallbackTitle +} + +/** + * Обрезает длинное имя файла для отображения + * @param fileName - Имя файла + * @param maxLength - Максимальная длина + * @returns Обрезанное имя файла + */ +export const truncateFileName = ( + fileName: string, + maxLength: number = 30 +): string => { + if (fileName.length <= maxLength) { + return fileName + } + + const extension = fileName.split('.').pop() || '' + const nameWithoutExt = fileName.slice(0, fileName.lastIndexOf('.')) + const truncatedName = nameWithoutExt.slice( + 0, + maxLength - extension.length - 4 + ) + + return `${truncatedName}...${extension}` +} diff --git a/src/shared/components/image-input/utils/file-reader.ts b/src/shared/components/image-input/utils/file-reader.ts new file mode 100644 index 0000000..ebb508b --- /dev/null +++ b/src/shared/components/image-input/utils/file-reader.ts @@ -0,0 +1,47 @@ +/** + * Утилиты для работы с файлами + */ + +/** + * Читает файл и возвращает его как Data URL + * @param file - Файл для чтения + * @returns Promise с Data URL строкой + */ +export const readFileAsDataURL = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Failed to read file as string')) + } + } + + reader.onerror = () => { + reject(new Error('Error reading file')) + } + + reader.readAsDataURL(file) + }) +} + +/** + * Проверяет, является ли файл изображением + * @param file - Файл для проверки + * @returns true если файл - изображение + */ +export const isImageFile = (file: File): boolean => { + return file.type.startsWith('image/') +} + +/** + * Получает расширение файла + * @param fileName - Имя файла + * @returns Расширение файла (например, 'jpg', 'png') + */ +export const getFileExtension = (fileName: string): string => { + const parts = fileName.split('.') + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '' +} diff --git a/src/shared/components/image-preview/image-preview.tsx b/src/shared/components/image-preview/image-preview.tsx new file mode 100644 index 0000000..c24da0e --- /dev/null +++ b/src/shared/components/image-preview/image-preview.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { cn } from '../../utils/cn' + +export interface ImagePreviewProps { + /** + * URL изображения для отображения + */ + imageUrl?: string + /** + * Alt текст для изображения + */ + alt?: string + /** + * Прозрачность изображения (0-100) + */ + opacity?: number + /** + * Размер preview (по умолчанию 'size-5') + */ + size?: 'size-4' | 'size-5' | 'size-6' | 'size-8' + /** + * Дополнительные классы + */ + className?: string + /** + * Обработчик клика + */ + onClick?: () => void + /** + * Disabled состояние + */ + disabled?: boolean + /** + * Цвет placeholder когда нет изображения + */ + placeholderColor?: string +} + +/** + * Компонент для отображения preview изображения с placeholder + */ +export const ImagePreview = React.forwardRef< + HTMLButtonElement, + ImagePreviewProps +>( + ( + { + imageUrl, + alt = 'Preview', + opacity = 100, + size = 'size-5', + className, + onClick, + disabled = false, + placeholderColor = 'bg-gray-200 dark:bg-gray-700', + }, + ref + ) => { + const content = imageUrl ? ( + {alt} + ) : ( +
+ ) + + const baseClasses = cn( + 'relative rounded-sm overflow-hidden bg-white flex-shrink-0', + size, + className + ) + + if (onClick) { + return ( + + ) + } + + return
{content}
+ } +) + +ImagePreview.displayName = 'ImagePreview' diff --git a/src/shared/components/input-container/input-container.tsx b/src/shared/components/input-container/input-container.tsx new file mode 100644 index 0000000..14c98de --- /dev/null +++ b/src/shared/components/input-container/input-container.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { cn } from '../../utils/cn' +import { INPUT_THEME_CLASSES } from '../../constants/input-theme' + +export interface InputContainerProps { + /** + * Дочерние элементы + */ + children: React.ReactNode + /** + * Дополнительные классы + */ + className?: string + /** + * Высота контейнера (по умолчанию h-8) + */ + height?: 'h-6' | 'h-8' | 'h-10' | 'h-12' + /** + * Вариант границы + */ + borderVariant?: 'default' | 'thick' + /** + * Показывать focus ring + */ + showFocusRing?: boolean +} + +/** + * Универсальный контейнер для input компонентов с темизацией + */ +export const InputContainer = React.forwardRef< + HTMLDivElement, + InputContainerProps +>( + ( + { + children, + className, + height = 'h-8', + borderVariant = 'thick', + showFocusRing = true, + }, + ref + ) => { + return ( +
+ {children} +
+ ) + } +) + +InputContainer.displayName = 'InputContainer' diff --git a/src/shared/components/input-select-modal/input-select-modal.tsx b/src/shared/components/input-select-modal/input-select-modal.tsx new file mode 100644 index 0000000..75314f8 --- /dev/null +++ b/src/shared/components/input-select-modal/input-select-modal.tsx @@ -0,0 +1,90 @@ +import { X } from 'lucide-react' +import { useDraggable } from '../../hooks/use-draggable' +import { cn } from '../../utils/cn' + +const THEME_CLASSES = { + light: { + modal: 'bg-white border-1 border-gray-300', + title: 'text-gray-900', + closeButton: 'text-gray-400 hover:text-gray-600', + }, + dark: { + modal: 'dark:bg-[#1e2939] dark:border-gray-600', + title: 'dark:text-gray-200', + closeButton: 'dark:text-gray-500 dark:hover:text-gray-200', + }, +} as const + +interface InputSelectModalProps { + title: string + onClose: () => void + children: React.ReactNode + inputRef: React.RefObject + className?: string + classNameHeader?: string + classNameTitle?: string + classNameCloseButton?: string + classNameContent?: string +} + +export function InputSelectModal({ + title, + onClose, + children, + inputRef, + className, + classNameHeader, + classNameTitle, + classNameCloseButton, + classNameContent, +}: InputSelectModalProps) { + const { position, handleDragStart } = useDraggable() + + return ( +
+
handleDragStart(e, inputRef.current)} + > + + {title} + + +
+ +
{children}
+
+ ) +} diff --git a/src/shared/components/opacity-drag-control/opacity-drag-control.tsx b/src/shared/components/opacity-drag-control/opacity-drag-control.tsx new file mode 100644 index 0000000..8972133 --- /dev/null +++ b/src/shared/components/opacity-drag-control/opacity-drag-control.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { cn } from '../../utils/cn' + +interface OpacityDragControlProps { + opacity: number + onChange: (value: number) => void + className?: string +} + +export const OpacityDragControl = ({ + opacity, + onChange, + className, +}: OpacityDragControlProps) => { + const [isDragging, setIsDragging] = useState(false) + + const handlePointerDown = (e: React.PointerEvent) => { + e.preventDefault() + const target = e.currentTarget + target.setPointerCapture(e.pointerId) + + const styleElement = document.createElement('style') + styleElement.id = 'opacity-dragging-cursor' + styleElement.innerHTML = ` + body, body * { + cursor: ew-resize !important; + user-select: none !important; + } + ` + document.head.appendChild(styleElement) + + const startX = e.clientX + const startOpacity = opacity + setIsDragging(true) + + const handlePointerMove = (event: PointerEvent) => { + const deltaX = event.clientX - startX + const step = 0.5 + let newOpacity = Math.round(startOpacity + deltaX * step) + newOpacity = Math.max(0, Math.min(100, newOpacity)) + onChange(newOpacity) + } + + const handlePointerUp = (event: PointerEvent) => { + target.releasePointerCapture(event.pointerId) + setIsDragging(false) + + const styleToRemove = document.getElementById('opacity-dragging-cursor') + if (styleToRemove) { + styleToRemove.remove() + } + + target.removeEventListener('pointermove', handlePointerMove) + document.removeEventListener('pointerup', handlePointerUp) + } + + target.addEventListener('pointermove', handlePointerMove) + document.addEventListener('pointerup', handlePointerUp) + } + + return ( + + ) +} diff --git a/src/shared/components/text-button/text-button.tsx b/src/shared/components/text-button/text-button.tsx new file mode 100644 index 0000000..7855a5e --- /dev/null +++ b/src/shared/components/text-button/text-button.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { cn } from '../../utils/cn' + +export interface TextButtonProps + extends Omit, 'children'> { + /** + * Текст для отображения + */ + children: React.ReactNode + /** + * Дополнительные классы + */ + className?: string + /** + * Классы для текста (light theme) + */ + textLightClass?: string + /** + * Классы для текста (dark theme) + */ + textDarkClass?: string +} + +/** + * Текстовая кнопка для отображения информации с возможностью клика + * Используется для gradient text, filename, labels + */ +export const TextButton = React.forwardRef( + ( + { + children, + className, + textLightClass = 'text-gray-700', + textDarkClass = 'dark:text-gray-300', + disabled = false, + ...props + }, + ref + ) => { + return ( + + ) + } +) + +TextButton.displayName = 'TextButton' diff --git a/src/shared/constants/input-theme.ts b/src/shared/constants/input-theme.ts new file mode 100644 index 0000000..a61c48a --- /dev/null +++ b/src/shared/constants/input-theme.ts @@ -0,0 +1,27 @@ +/** + * Общие theme классы для input компонентов + * Используется в InputNumberSelect, InputHex, InputHexWithPreview, InputColorPicker + */ +export const INPUT_THEME_CLASSES = { + light: { + container: 'bg-white border-gray-300 focus-within:ring-blue-500', + input: 'text-gray-900', + text: 'text-gray-900', + textMuted: 'text-gray-600', + icon: 'text-gray-600', + dragArea: 'bg-gray-100 hover:bg-gray-200', + preview: 'border-gray-300', + divider: 'bg-gray-300', + }, + dark: { + container: + 'dark:bg-gray-800 dark:border-gray-600 dark:focus-within:ring-blue-400', + input: 'dark:text-gray-100', + text: 'dark:text-gray-200', + textMuted: 'dark:text-gray-400', + icon: 'dark:text-gray-300', + dragArea: 'dark:bg-gray-700 dark:hover:bg-gray-600', + preview: 'dark:border-gray-600', + divider: 'dark:bg-gray-600', + }, +} as const diff --git a/src/shared/hooks/use-draggable-input.ts b/src/shared/hooks/use-draggable-input.ts new file mode 100644 index 0000000..a2ebe6a --- /dev/null +++ b/src/shared/hooks/use-draggable-input.ts @@ -0,0 +1,101 @@ +import { useRef } from 'react' + +export type DragOrientation = 'horizontal' | 'vertical' + +export interface UseDraggableInputOptions { + /** + * Ориентация драга (horizontal или vertical) + */ + orientation?: DragOrientation + /** + * Функция для вычисления нового значения на основе движения + */ + onDragChange: (delta: number, startValue: number) => void + /** + * Callback при начале драга + */ + onDragStart?: () => void + /** + * Callback при окончании драга + */ + onDragEnd?: () => void + /** + * Отключить drag + */ + disabled?: boolean +} + +/** + * Хук для реализации drag-to-change функционала в input компонентах + * Используется в InputNumberSelect, InputHex, InputHexWithPreview + */ +export const useDraggableInput = ({ + orientation = 'horizontal', + onDragChange, + onDragStart, + onDragEnd, + disabled = false, +}: UseDraggableInputOptions) => { + const dragStartValueRef = useRef(0) + const startPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + + const handlePointerDown = ( + e: React.PointerEvent, + currentValue: number + ) => { + if (disabled) return + e.preventDefault() + + const target = e.currentTarget + target.setPointerCapture(e.pointerId) + + // Создаём динамический стиль для курсора + const styleElement = document.createElement('style') + styleElement.id = 'dragging-cursor-style' + const cursorType = orientation === 'vertical' ? 'ns-resize' : 'ew-resize' + styleElement.innerHTML = ` + body, body * { + cursor: ${cursorType} !important; + user-select: none !important; + } + ` + document.head.appendChild(styleElement) + + // Сохраняем начальные значения + dragStartValueRef.current = currentValue + startPositionRef.current = { x: e.clientX, y: e.clientY } + + onDragStart?.() + + const handlePointerMove = (event: PointerEvent) => { + const deltaX = event.clientX - startPositionRef.current.x + const deltaY = startPositionRef.current.y - event.clientY // Инвертируем для vertical + + const delta = orientation === 'vertical' ? deltaY : deltaX + + onDragChange(delta, dragStartValueRef.current) + } + + const handlePointerUp = (event: PointerEvent) => { + target.releasePointerCapture(event.pointerId) + + // Удаляем стиль курсора + const styleToRemove = document.getElementById('dragging-cursor-style') + if (styleToRemove) { + styleToRemove.remove() + } + + target.removeEventListener('pointermove', handlePointerMove) + document.removeEventListener('pointerup', handlePointerUp) + + onDragEnd?.() + } + + target.addEventListener('pointermove', handlePointerMove) + document.addEventListener('pointerup', handlePointerUp) + } + + return { + handlePointerDown, + } +} diff --git a/src/input-color-picker/hooks/use-draggable.ts b/src/shared/hooks/use-draggable.ts similarity index 94% rename from src/input-color-picker/hooks/use-draggable.ts rename to src/shared/hooks/use-draggable.ts index f6fd022..fa666a6 100644 --- a/src/input-color-picker/hooks/use-draggable.ts +++ b/src/shared/hooks/use-draggable.ts @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import type { DragStart, Position } from '../types' +import type { DragStart, Position } from '../../input-color-picker/types' export const useDraggable = () => { const [position, setPosition] = useState({ diff --git a/src/shared/hooks/use-hex-input.ts b/src/shared/hooks/use-hex-input.ts new file mode 100644 index 0000000..788c4dd --- /dev/null +++ b/src/shared/hooks/use-hex-input.ts @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' +import tc from 'tinycolor2' +import { convertToHex8, filterHexInput } from '../utils/color-utils' + +export interface UseHexInputOptions { + /** + * Входное hex значение (может быть с # или без) + */ + hexColor: string + /** + * Callback при изменении hex значения + */ + onChange: (hex: string) => void + /** + * Показывать и разрешать ввод alpha канала (еще 2 hex символа) + * По умолчанию false (только 6 символов) + */ + showAlpha?: boolean +} + +export interface UseHexInputReturn { + /** + * Локальное hex значение (без #) + */ + localHex: string + /** + * Флаг редактирования + */ + isEditing: boolean + /** + * Обработчик изменения input + */ + handleHexInput: (e: React.ChangeEvent) => void + /** + * Установить флаг редактирования + */ + setIsEditing: (editing: boolean) => void + /** + * Установить локальное hex значение напрямую (для drag-логики) + */ + setLocalHex: (hex: string) => void +} + +/** + * Хук для управления hex input с поддержкой alpha канала + * Используется в InputHex и InputHexWithPreview + */ +export const useHexInput = ({ + hexColor, + onChange, + showAlpha = false, +}: UseHexInputOptions): UseHexInputReturn => { + const [isEditing, setIsEditing] = useState(false) + + // Нормализация входного значения через tinycolor2 + const color = tc(hexColor) + const hex = color.toHex() // 6 символов без # + const alpha = color.getAlpha() // 0-1 + const alphaHex = Math.round(alpha * 255) + .toString(16) + .padStart(2, '0') + + // Полное значение: если showAlpha и alpha < 1, добавляем alpha + const hexFromProp = showAlpha && alpha < 1 ? hex + alphaHex : hex + + const [localHex, setLocalHex] = useState(hexFromProp) + + // Синхронизация с props, когда не редактируем + useEffect(() => { + if (!isEditing) { + setLocalHex(hexFromProp) + } + }, [hexColor, isEditing, hexFromProp]) + + const handleHexInput = (e: React.ChangeEvent) => { + const maxLen = showAlpha ? 8 : 6 + // Фильтруем только hex символы, обрезаем по длине + const filtered = filterHexInput(e.target.value, maxLen) + + setLocalHex(filtered) + + // Отправляем изменение только когда полная длина + if (filtered.length === 6) { + onChange(`#${filtered}`) + } else if (filtered.length === 8 && showAlpha) { + onChange(convertToHex8(filtered)) + } + } + + return { + localHex, + isEditing, + handleHexInput, + setIsEditing, + setLocalHex, + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 9e416a9..36a3da2 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,2 +1,53 @@ export { cn } from './utils/cn' export { removeTrailingZeros } from './utils/remove-trailing-zeros' +export { INPUT_THEME_CLASSES } from './constants/input-theme' +export { + ColorPreview, + type ColorPreviewProps, +} from './components/color-preview/color-preview' +export { + ImagePreview, + type ImagePreviewProps, +} from './components/image-preview/image-preview' +export { + IconButton, + type IconButtonProps, +} from './components/icon-button/icon-button' +export { + InputContainer, + type InputContainerProps, +} from './components/input-container/input-container' +export { + ActionButtons, + type ActionButtonsProps, +} from './components/action-buttons/action-buttons' +export { + DragButton, + type DragButtonProps, +} from './components/drag-button/drag-button' +export { + TextButton, + type TextButtonProps, +} from './components/text-button/text-button' +export { + useDraggableInput, + type DragOrientation, + type UseDraggableInputOptions, +} from './hooks/use-draggable-input' +export { + useHexInput, + type UseHexInputOptions, + type UseHexInputReturn, +} from './hooks/use-hex-input' +export { + convertToHex8, + opacityToHex, + alphaToHex, + filterHexInput, + parseColor, + rgbToHex, + hexToRgb, + createDisplayColor, + type RGB, + type ParsedColor, +} from './utils/color-utils' diff --git a/src/shared/utils/color-utils.ts b/src/shared/utils/color-utils.ts new file mode 100644 index 0000000..da6483a --- /dev/null +++ b/src/shared/utils/color-utils.ts @@ -0,0 +1,208 @@ +import tc from 'tinycolor2' + +export interface RGB { + r: number + g: number + b: number +} + +export interface ParsedColor { + hex: string + opacity: number +} + +/** + * Конвертирует 8-символьный hex (с alpha) в формат с alpha каналом + * @param hex8 - 8-символьный hex без #, например "FF0000FF" + * @returns Hex8 строка в формате #RRGGBBAA + */ +export const convertToHex8 = (hex8: string): string => { + const base = hex8.slice(0, 6) + const alphaHex = hex8.slice(6, 8) + const alphaDecimal = parseInt(alphaHex, 16) / 255 + return tc(`#${base}`).setAlpha(alphaDecimal).toHex8String().toUpperCase() +} + +/** + * Парсит строку цвета в HEX и прозрачность + * @param colorStr - Строка цвета (hex, rgb, rgba, gradient) + * @returns Объект с hex и opacity + */ +export const parseColor = (colorStr: string): ParsedColor => { + if (!colorStr) return { hex: '------', opacity: 100 } + + // Обработка градиентов + if (colorStr.includes('gradient')) { + return { hex: 'Linear', opacity: 100 } + } + + // Обработка HEX + if (colorStr.startsWith('#')) { + return { hex: colorStr.toUpperCase(), opacity: 100 } + } + + // Обработка RGB/RGBA + const rgbaMatch = colorStr.match( + /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/ + ) + if (rgbaMatch) { + const r = parseInt(rgbaMatch[1]) + const g = parseInt(rgbaMatch[2]) + const b = parseInt(rgbaMatch[3]) + const a = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1 + const componentToHex = (c: number) => { + const hex = c.toString(16) + return hex.length == 1 ? '0' + hex : hex + } + const hex = '#' + componentToHex(r) + componentToHex(g) + componentToHex(b) + return { hex: hex.toUpperCase(), opacity: Math.round(a * 100) } + } + + // Fallback для невалидных цветов + return { hex: colorStr, opacity: 100 } +} + +/** + * Конвертирует RGB строку в HEX + * @param rgbStr - RGB/RGBA строка + * @returns HEX строка + */ +export const rgbToHex = (rgbStr: string): string => { + if (!rgbStr) return '#000000' + + // Если уже HEX, возвращаем как есть + if (rgbStr.startsWith('#')) return rgbStr.toUpperCase() + + // Парсим rgb(r,g,b) или rgba(r,g,b,a) + const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/) + + if (match) { + const r = parseInt(match[1], 10) + const g = parseInt(match[2], 10) + const b = parseInt(match[3], 10) + + const toHex = (c: number) => ('0' + c.toString(16)).slice(-2) + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase() + } + + return '#000000' +} + +/** + * Конвертирует HEX в RGB + * @param hexStr - HEX строка + * @returns RGB объект или null + */ +export const hexToRgb = (hexStr: string): RGB | null => { + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i + const fullHex = hexStr.replace( + shorthandRegex, + (_m, r, g, b) => r + r + g + g + b + b + ) + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(fullHex) + if (!result) return null + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } +} + +/** + * Создает display color с учетом прозрачности + * @param livePreviewColor - Цвет для preview + * @param opacityValue - Прозрачность 0-100 + * @returns Display color строка + */ +export const createDisplayColor = ( + livePreviewColor: string, + opacityValue: number +): string => { + let displayColor = + livePreviewColor && + (livePreviewColor.includes('gradient') || + livePreviewColor.startsWith('rgba') || + livePreviewColor.startsWith('rgb') || + livePreviewColor.startsWith('#')) + ? livePreviewColor + : '#FFFFFF' + + // Обработка градиентов - возвращаем как есть + if (displayColor.includes('gradient')) { + return displayColor + } + + // Нормализация rgb/rgba -> rgba с учётом текущей opacityValue + if (displayColor.startsWith('rgb')) { + const match = displayColor.match( + /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/ + ) + if (match) { + const r = parseInt(match[1], 10) + const g = parseInt(match[2], 10) + const b = parseInt(match[3], 10) + const a = match[4] !== undefined ? parseFloat(match[4]) : undefined + const finalA = isNaN(opacityValue) + ? typeof a === 'number' + ? a + : 1 + : opacityValue / 100 + displayColor = `rgba(${r}, ${g}, ${b}, ${finalA})` + } + } + + // HEX -> HEXA с учётом opacityValue + if (displayColor.startsWith('#')) { + let hexVal = displayColor.substring(1) + if (hexVal.length === 3 || hexVal.length === 4) { + hexVal = hexVal + .split('') + .map(char => char + char) + .join('') + } + const rgbHex = hexVal.substring(0, 6) + displayColor = `#${rgbHex}${opacityToHex(opacityValue)}` + } + + return displayColor +} + +/** + * Конвертирует opacity (0-100) в hex формат (00-FF) + * @param opacity - Прозрачность от 0 до 100 + * @returns Hex строка из 2 символов + */ +export const opacityToHex = (opacity: number): string => { + if (opacity < 0 || opacity > 100) return 'FF' + const alpha = Math.round((opacity / 100) * 255) + return alpha.toString(16).padStart(2, '0').toUpperCase() +} + +/** + * Конвертирует alpha (0-1) в hex формат (00-FF) + * @param alpha - Прозрачность от 0 до 1 + * @returns Hex строка из 2 символов + */ +export const alphaToHex = (alpha: number): string => { + if (typeof alpha !== 'number') return 'FF' + if (alpha < 0) return '00' + if (alpha > 1) return 'FF' + return Math.round(alpha * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase() +} + +/** + * Фильтрует ввод для hex значения (удаляет невалидные символы) + * @param value - Входная строка + * @param maxLength - Максимальная длина (по умолчанию 6) + * @returns Отфильтрованная hex строка + */ +export const filterHexInput = (value: string, maxLength: number = 6): string => { + return value + .replace(/[^0-9a-fA-F]/g, '') + .slice(0, maxLength) + .toUpperCase() +} diff --git a/src/stories/ColorPicker.stories.tsx b/src/stories/ColorPicker.stories.tsx index 159c0f5..54530f5 100644 --- a/src/stories/ColorPicker.stories.tsx +++ b/src/stories/ColorPicker.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react' -import React, { useState } from 'react' +import { useState } from 'react' import { addons } from 'storybook/manager-api' import { themes } from 'storybook/theming' import { ColorPicker } from '../color-picker' @@ -74,19 +74,21 @@ export const Basic: Story = {
{/* Карточка-презентация */}
-

Basic Color Picker

+

+ Basic Color Picker +

Selected Color
-
{/* Чекерборд + цвет поверх */}
-
+
-

+

Gradient Color Picker

@@ -295,10 +300,9 @@ export const WithGradient: Story = { />
-
+
- -// All Components Together -export const AllComponents: Story = { - render: () => { - const [color, setColor] = useState('rgba(175, 51, 242, 1)') - const [opacity, setOpacity] = useState(100) - const [hue, setHue] = useState(280) - - return ( -
-

- Color Picker Library - Complete Demo -

-

- This demo showcases all components working together to create a - comprehensive color selection experience. -

- -
- {/* Left Column - Main Color Picker */} -
-

Main Color Picker

- -
-

Selected Color: {color}

-
-
-
- - {/* Right Column - Controls */} -
-

Advanced Controls

- - {/* Opacity Control */} -
-

Opacity Control

- -

Opacity: {opacity}%

-
- - {/* Hue Control */} -
-

Hue Control

- -

Hue: {hue}°

-
-
-
- - {/* Input Color Picker Section */} -
-

Input Color Picker

- -
- - {/* Color Formats Comparison */} -
-

Color Formats

-
-
-

HEX

-

{color}

-
-
-

RGB

-

{color}

-
-
-

HSL

-

{color}

-
-
-
-
- ) - }, -} diff --git a/src/stories/InputImageSelect.stories.tsx b/src/stories/InputImageSelect.stories.tsx new file mode 100644 index 0000000..64475be --- /dev/null +++ b/src/stories/InputImageSelect.stories.tsx @@ -0,0 +1,348 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useEffect, useState } from 'react' +import { InputImageSelect, type ImageFilters } from '../input-image-select' +import { rotateImage90Degrees } from '../input-image-select/utils' + +const computeRotationScale = ( + containerWidth: number, + containerHeight: number, + imageWidth: number, + imageHeight: number, + rotation: number +) => { + if (!containerWidth || !containerHeight || !imageWidth || !imageHeight) { + return 1 + } + + const baseScale = Math.max( + containerWidth / imageWidth, + containerHeight / imageHeight + ) + const scaledWidth = imageWidth * baseScale + const scaledHeight = imageHeight * baseScale + const radians = (rotation * Math.PI) / 180 + const cos = Math.abs(Math.cos(radians)) + const sin = Math.abs(Math.sin(radians)) + const rotatedWidth = scaledWidth * cos + scaledHeight * sin + const rotatedHeight = scaledWidth * sin + scaledHeight * cos + const adjustScale = Math.max( + 1, + containerWidth / rotatedWidth, + containerHeight / rotatedHeight + ) + const scale = baseScale * adjustScale + return Number.isFinite(scale) ? scale : 1 +} + +const meta = { + title: 'Components/InputImageSelect', + component: InputImageSelect, + parameters: { + layout: 'centered', + docs: { + toc: { + title: 'Table of Contents', + }, + }, + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#ffffff' }, + { name: 'dark', value: '#1a1a1a' }, + ], + }, + }, + tags: ['autodocs'], + argTypes: { + imageUrl: { + control: 'text', + description: 'Current image URL', + }, + onImageChange: { + description: 'Callback when image changes', + }, + onHideImage: { + description: 'Callback when image visibility toggles', + }, + onDeleteImage: { + description: 'Callback when image is deleted', + }, + className: { + control: 'text', + description: 'Custom classes for the container', + }, + title: { + control: 'text', + description: 'Title shown in picker header', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * Basic example of InputImageSelect component. + * Use Controls to change parameters. + * Switch background in toolbar to change theme (light/dark). + */ +export const Default: Story = { + args: { + title: 'Image', + }, + render: () => { + const [imageUrl, setImageUrl] = useState(undefined) + + const handleImageChange = (file: File | null) => { + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + setImageUrl(reader.result as string) + } + reader.readAsDataURL(file) + } + } + + return ( +
+ +

+ {imageUrl ? 'Image selected' : 'No image selected'} +

+
+ ) + }, +} + +/** + * ## Background Image + * Component for controlling element background image. + */ +export const BackgroundImage: Story = { + args: { + title: 'Background Image', + }, + render: () => { + const [imageUrl, setImageUrl] = useState( + 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop' + ) + + const handleImageChange = (file: File | null) => { + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + setImageUrl(reader.result as string) + } + reader.readAsDataURL(file) + } + } + + return ( +
+ +
+ {!imageUrl && 'No Background'} +
+
+ ) + }, +} + +/** + * ## With Actions + * Demonstrating all available actions: hide and delete. + */ +export const WithActions: Story = { + args: { + title: 'Image with Actions', + }, + render: () => { + const [imageUrl, setImageUrl] = useState( + 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop' + ) + const [isHidden, setIsHidden] = useState(false) + const [rotation, setRotation] = useState(0) + const [rotatedImageUrl, setRotatedImageUrl] = useState( + imageUrl + ) + const [isRotating, setIsRotating] = useState(false) + const [opacityValue, setOpacityValue] = useState(100) + + const handleImageChange = (file: File | null) => { + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + const url = reader.result as string + setImageUrl(url) + setRotatedImageUrl(url) + setRotation(0) + } + reader.readAsDataURL(file) + } + } + + const handleDelete = () => { + setImageUrl(undefined) + setRotatedImageUrl(undefined) + setRotation(0) + } + + const handleHide = (hidden: boolean) => { + setIsHidden(hidden) + } + + useEffect(() => { + if (!imageUrl) { + setRotatedImageUrl(undefined) + return + } + + if (rotation === 0) { + setRotatedImageUrl(imageUrl) + return + } + + const applyRotation = async () => { + setIsRotating(true) + try { + const rotated = await rotateImage90Degrees(imageUrl, rotation) + setRotatedImageUrl(rotated) + } catch (error) { + console.error('Failed to rotate image:', error) + setRotatedImageUrl(imageUrl) + } finally { + setIsRotating(false) + } + } + + applyRotation() + }, [imageUrl, rotation]) + + return ( +
+ +
+ {isRotating ? ( + + Rotating... + + ) : rotatedImageUrl && !isHidden ? ( + Canvas preview + ) : ( + + {!imageUrl ? 'No Image' : 'Hidden'} + + )} +
+

+ Use eye icon to hide/show image, trash icon to delete it +

+
+ ) + }, +} + +/** + * ## With Image Filters + * Demonstrating image filters: exposure, contrast, saturation, temperature, tint, highlights, and shadows. + * Filters are applied in real-time and can be used to adjust the image appearance. + */ +export const WithImageFilters: Story = { + args: { + title: 'Image with Filters', + }, + render: () => { + const [imageUrl, setImageUrl] = useState( + 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop' + ) + const [filters, setFilters] = useState({ + exposure: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + highlights: 0, + shadows: 0, + }) + + const handleImageChange = (file: File | null) => { + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + setImageUrl(reader.result as string) + } + reader.readAsDataURL(file) + } + } + + const handleFiltersChange = (newFilters: ImageFilters) => { + setFilters(newFilters) + } + + const getFilterStyle = () => { + const brightness = 1 + filters.exposure / 100 + const contrast = 1 + filters.contrast / 100 + const saturate = 1 + filters.saturation / 100 + const hueRotate = filters.temperature * 1.8 + + return { + filter: `brightness(${brightness}) contrast(${contrast}) saturate(${saturate}) hue-rotate(${hueRotate}deg)`, + } + } + + return ( +
+ +
+ {!imageUrl && 'No Image'} +
+

+ Click on the image to open the modal and adjust filters using sliders +

+
+ ) + }, +} diff --git a/src/stories/InputUploadImage.stories.tsx b/src/stories/InputUploadImage.stories.tsx new file mode 100644 index 0000000..3281191 --- /dev/null +++ b/src/stories/InputUploadImage.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { InputUploadImage } from '../input-upload-image' + +const meta = { + title: 'Components/InputUploadImage', + component: InputUploadImage, + parameters: { + layout: 'centered', + docs: { + toc: { + title: 'Table of Contents', + }, + }, + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#ffffff' }, + { name: 'dark', value: '#1a1a1a' }, + ], + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + title: 'Upload Image', + }, +} + +export const Interactive: Story = { + args: { + title: 'Image Upload', + }, + render: () => { + const [imageUrl, setImageUrl] = useState(undefined) + const [fileName, setFileName] = useState(undefined) + const [isHidden, setIsHidden] = useState(false) + + const handleImageChange = (file: File | null) => { + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + setImageUrl(reader.result as string) + setFileName(file.name) + } + reader.readAsDataURL(file) + } + } + + const handleDelete = () => { + setImageUrl(undefined) + setFileName(undefined) + setIsHidden(false) + } + + const handleHide = (hidden: boolean) => { + setIsHidden(hidden) + } + + return ( +
+ + + {imageUrl && ( +
+ {isHidden ? ( + + Image Hidden + + ) : ( + Preview + )} +
+ )} + + {fileName && ( +
+

+ Selected file: +

+

+ {fileName} +

+
+ )} +
+ ) + }, +} + +export const WithCustomTitle: Story = { + args: { + title: 'product-image.png', + imageUrl: + 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=100&h=100&fit=crop', + fileName: 'product-image.png', + }, +} From f3441e402c890c8b1b6c0d66b53dbcd31a429fc5 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Sun, 12 Oct 2025 09:42:30 +0300 Subject: [PATCH 5/7] fix: imput-image-select rotation button visibility --- src/input-color-picker/hooks/index.ts | 1 - .../components/image-picker-modal.tsx | 2 +- src/input-number-select/input-number-select.tsx | 14 ++++++++------ src/shared/components/icon-button/icon-button.tsx | 2 +- .../image-input/hooks/use-image-actions.ts | 1 - src/shared/utils/color-utils.ts | 5 ++++- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/input-color-picker/hooks/index.ts b/src/input-color-picker/hooks/index.ts index f9d5af3..24903e9 100644 --- a/src/input-color-picker/hooks/index.ts +++ b/src/input-color-picker/hooks/index.ts @@ -1,2 +1 @@ export * from './use-color-picker-state' - diff --git a/src/input-image-select/components/image-picker-modal.tsx b/src/input-image-select/components/image-picker-modal.tsx index ad1fecd..48b3bc5 100644 --- a/src/input-image-select/components/image-picker-modal.tsx +++ b/src/input-image-select/components/image-picker-modal.tsx @@ -240,7 +240,7 @@ export const ImagePickerModal = ({ >
- {isRotateButtonActive && ( + {isRotateButtonActive && imageUrl && (