UNPKG

@zentered/solid-image-crop

Version:

Solid.js simple image crop tool using Cropper.js

227 lines (216 loc) 7.84 kB
import { createEffect, createSignal, Show, on, untrack } from 'solid-js' import { createStore } from 'solid-js/store' import 'cropperjs' import { Icon } from 'solid-heroicons' import { cloudArrowUp, photo } from 'solid-heroicons/solid' /** * ImageDrop component provides a drag-and-drop interface for uploading and cropping images. * * @param {Object} props - Component props * @param {Function} props.saveImage - Callback function called when the user saves the cropped image. Receives the state object containing error, loading, file, and croppedImage. * @param {number} [props.aspectRatioWidth=1] - Initial aspect ratio width * @param {number} [props.aspectRatioHeight=1] - Initial aspect ratio height * @param {string[]} [props.acceptedFileTypes=['image/jpeg', 'image/png', 'image/webp', 'image/gif']] - Array of accepted file MIME types * @param {number} [props.maxFileSizeMB=10] - Maximum file size in megabytes * @param {Function} [props.onError] - Optional callback function called when an error occurs. Receives the error message as a string. * @returns {JSX.Element} The ImageDrop component */ export default function ImageDrop(props) { let cropperImage let cropperSelection // Set default values for validation props const acceptedFileTypes = props.acceptedFileTypes || ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] const maxFileSizeMB = props.maxFileSizeMB || 10 const maxFileSizeBytes = maxFileSizeMB * 1024 * 1024 const [state, setState] = createStore({ error: null, loading: false, file: {}, croppedImage: null }), [aspectRatioWidth, setAspectRatioWidth] = createSignal(1), [aspectRatioHeight, setAspectRatioHeight] = createSignal(1), [dropZoneActive, setDropZoneActive] = createSignal(false), [uploading, setUploading] = createSignal(false), [preview, setPreview] = createSignal(null), noPropagate = (e) => { e.preventDefault() }, handleError = (message) => { setState('error', message) if (props.onError) { props.onError(message) } }, uploadFile = async (file) => { if (!file) return // Reset error state setState('error', null) // Validate file type if (!acceptedFileTypes.includes(file.type)) { const message = `Invalid file type. Please upload one of: ${acceptedFileTypes.join(', ')}` handleError(message) return } // Validate file size if (file.size > maxFileSizeBytes) { const message = `File size exceeds ${maxFileSizeMB}MB limit. Current file: ${(file.size / 1024 / 1024).toFixed(2)}MB` handleError(message) return } setUploading(true) setState('loading', true) setState('file', file) try { const reader = new FileReader() reader.onload = (e) => { setPreview(e.target.result) } reader.readAsDataURL(file) } catch (e) { console.error('upload failed', e) const message = e instanceof Error ? e.message : String(e) handleError(message) } setState('loading', false) setUploading(false) }, setAspectRatio = (width, height) => { setAspectRatioWidth(width) setAspectRatioHeight(height) if (cropperSelection) { cropperSelection.aspectRatio = aspectRatioWidth() / aspectRatioHeight() } }, handleFileDrop = async (e) => { e.preventDefault() setDropZoneActive(false) uploadFile(e.dataTransfer.files[0]) }, handleFileInput = async (e) => { e.preventDefault() uploadFile(e.currentTarget.files[0]) } createEffect(() => { if (props.aspectRatioWidth >= 1 && props.aspectRatioHeight >= 1) { setAspectRatio(props.aspectRatioWidth, props.aspectRatioHeight) } }) createEffect( on(preview, () => { if (cropperImage && preview()) { cropperImage.src = preview() } }) ) return ( <> <Show when={preview() !== null}> <div> <div> <cropper-canvas background class=""> <cropper-image ref={cropperImage} rotatable scalable skewable translatable alt="cropper" /> <cropper-handle action="select" plain /> <cropper-selection ref={cropperSelection} initial-coverage="0.5" dynamic movable resizable zoomable > <cropper-grid role="grid" covered></cropper-grid> <cropper-crosshair centered></cropper-crosshair> <cropper-handle action="select" theme-color="rgba(255, 255, 255, 0.35)" ></cropper-handle> <cropper-handle action="n-resize"></cropper-handle> <cropper-handle action="e-resize"></cropper-handle> <cropper-handle action="s-resize"></cropper-handle> <cropper-handle action="w-resize"></cropper-handle> <cropper-handle action="ne-resize"></cropper-handle> <cropper-handle action="nw-resize"></cropper-handle> <cropper-handle action="se-resize"></cropper-handle> <cropper-handle action="sw-resize"></cropper-handle> </cropper-selection> </cropper-canvas> </div> <button type="button" class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 mt-2" onClick={async () => { setState( 'croppedImage', await cropperSelection() ?.$toCanvas() .then((canvas) => { return canvas.toDataURL(untrack(state).file.type) }) ) props.saveImage(state) }} > <Icon path={cloudArrowUp} class="-ml-0.5 h-5 w-5" aria-hidden="true" /> Save </button> </div> </Show> <Show when={preview() === null}> <form class="min-h-96 min-w-96"> <div id="dropzone" class={`${dropZoneActive() ? 'bg-green-100' : ''} ${ uploading() && 'opacity-50' } place-content-center place-items-center h-96 w-96 border-2 border-gray-300 border-dashed rounded-md sm:flex p-2 m-2`} onDragEnter={() => uploading() ? undefined : setDropZoneActive(true) } onDragLeave={() => setDropZoneActive(false)} onDragOver={noPropagate} onDrop={(event) => uploading() ? noPropagate(event) : handleFileDrop(event) } > <div class=""> <Icon path={photo} class="h-48 w-48 text-gray-300" /> </div> <input id="image-upload" name="file" type="file" accept={acceptedFileTypes.join(',')} disabled={uploading()} multiple={false} onInput={handleFileInput} class="sr-only" /> </div> <Show when={state.error}> <div class="mt-4 p-4 bg-red-100 border border-red-400 text-red-700 rounded-md"> <p class="text-sm">{state.error}</p> </div> </Show> <div class="h-8" /> </form> </Show> </> ) }