index.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import React, { CSSProperties, FC, HTMLAttributes, InputHTMLAttributes, useEffect, useRef, useState } from 'react';
  2. const supportsInputDirs = 'webkitdirectory' in HTMLInputElement.prototype;
  3. const supportsRelativePath = 'webkitRelativePath' in File.prototype;
  4. const supportsDirs = typeof DataTransferItem != 'undefined' && 'webkitGetAsEntry' in DataTransferItem.prototype;
  5. const readRecurse = (dir: FileSystemDirectoryEntry, onComplete: (files: File[]) => void, onError: (err: Error) => void) => {
  6. let files: File[] = [];
  7. let total = 0;
  8. let errored = false;
  9. let reachedEnd = false;
  10. const onErr = (err: Error) => {
  11. if (!errored) {
  12. errored = true;
  13. onError(err);
  14. }
  15. };
  16. const onDone = (f: File[]) => {
  17. files = files.concat(f);
  18. if (!--total && reachedEnd) onComplete(files);
  19. };
  20. const reader = dir.createReader();
  21. const onRead = (entries: FileSystemEntry[]) => {
  22. if (!entries.length && !errored) {
  23. if (!total) onComplete(files);
  24. else reachedEnd = true;
  25. } else reader.readEntries(onRead, onError);
  26. for (const entry of entries) {
  27. ++total;
  28. if (entry.isFile) entry.file(f => onDone([
  29. new File([f], entry.fullPath.slice(1), f)
  30. ]), onErr);
  31. else readRecurse(entry as FileSystemDirectoryEntry, onDone, onErr);
  32. }
  33. };
  34. reader.readEntries(onRead, onError);
  35. }
  36. const FilePicker: FC<{
  37. onFiles(files: File[] | null): void;
  38. onDrag(on: boolean): void;
  39. onError(err: string | Error): void;
  40. allowDirs: boolean;
  41. } & Omit<HTMLAttributes<HTMLDivElement>, 'onError'>
  42. > = ({ onFiles, onDrag, onError, style, allowDirs, children, ...props }) => {
  43. const inputRef = useRef<HTMLInputElement>(null);
  44. const dirInputRef = useRef<HTMLInputElement>(null);
  45. const dragRef = useRef(0);
  46. const [inputHover, setInputHover] = useState(false);
  47. const [dirInputHover, setDirInputHover] = useState(false);
  48. const [isHovering, setIsHovering] = useState(false);
  49. useEffect(() => {
  50. // only init'd when support dirs
  51. if (dirInputRef.current) {
  52. dirInputRef.current.setAttribute('webkitdirectory', '');
  53. }
  54. }, []);
  55. const rootProps: HTMLAttributes<HTMLDivElement> = {
  56. onDrop(ev) {
  57. ev.preventDefault();
  58. const tf = ev.dataTransfer;
  59. if (!tf.files.length) onError('Please drop some files in');
  60. else {
  61. onFiles(null);
  62. if (supportsDirs && allowDirs) {
  63. let outFiles: File[] = [];
  64. let lft = tf.items.length;
  65. let errored = false;
  66. const onErr = (err: Error) => {
  67. if (!errored) {
  68. errored = true;
  69. onError(err);
  70. }
  71. }
  72. const onDone = (f: File[]) => {
  73. outFiles = outFiles.concat(f);
  74. if (!--lft && !errored) onFiles(outFiles);
  75. };
  76. for (let i = 0; i < tf.items.length; ++i) {
  77. const entry = tf.items[i].webkitGetAsEntry();
  78. if (entry.isFile) entry.file(f => onDone([f]), onErr);
  79. else readRecurse(entry as FileSystemDirectoryEntry, onDone, onErr);
  80. }
  81. } else onFiles(Array.prototype.slice.call(tf.files));
  82. }
  83. setIsHovering(false);
  84. },
  85. onDragEnter() {
  86. ++dragRef.current;
  87. onDrag(true);
  88. setIsHovering(true);
  89. },
  90. onDragOver(ev) {
  91. ev.preventDefault();
  92. },
  93. onDragLeave() {
  94. if (!--dragRef.current) {
  95. onDrag(false);
  96. setIsHovering(false);
  97. }
  98. },
  99. style: {
  100. display: 'flex',
  101. flexDirection: 'column',
  102. alignItems: 'center',
  103. ...style
  104. }
  105. };
  106. const inputProps: InputHTMLAttributes<HTMLInputElement> = {
  107. onInput(ev) {
  108. const t = ev.currentTarget, files = t.files!;
  109. if (supportsRelativePath) {
  110. const outFiles: File[] = Array(files.length);
  111. for (let i = 0; i < files.length; ++i) {
  112. const file = files[i];
  113. outFiles[i] = new File([file], file.webkitRelativePath || file.name, file);
  114. }
  115. onFiles(outFiles);
  116. } else onFiles(Array.prototype.slice.call(files));
  117. t.value = '';
  118. },
  119. style: { display: 'none' },
  120. multiple: true
  121. };
  122. const buttonStyles: CSSProperties = {
  123. cursor: 'default',
  124. minWidth: '8vw',
  125. height: '6vh',
  126. margin: '1vmin',
  127. padding: '1vmin',
  128. display: 'flex',
  129. alignItems: 'center',
  130. justifyContent: 'center',
  131. boxShadow: '0 1px 2px 1px rgba(0, 0, 0, 0.2), 0 2px 4px 2px rgba(0, 0, 0, 0.15), 0 4px 8px 4px rgba(0, 0, 0, 0.12)',
  132. border: '1px solid black',
  133. borderRadius: '6px',
  134. transition: 'background-color 300ms ease-in-out',
  135. WebkitTouchCallout: 'none',
  136. WebkitUserSelect: 'none',
  137. msUserSelect: 'none',
  138. MozUserSelect: 'none',
  139. userSelect: 'none'
  140. };
  141. return (
  142. <div {...props} {...rootProps}>
  143. {children}
  144. <div style={{
  145. transition: 'transform ' + (isHovering ? 300 : 50) + 'ms ease-in-out',
  146. transform: isHovering ? 'scale(1.5)' : 'none'
  147. }}>Drag and Drop</div>
  148. <div style={{
  149. borderBottom: '1px solid gray',
  150. margin: '1.5vh',
  151. color: 'gray',
  152. lineHeight: 0,
  153. paddingTop: '1.5vh',
  154. marginBottom: '3vh',
  155. width: '100%',
  156. }}>
  157. <span style={{ background: 'white', padding: '0.25em' }}>OR</span>
  158. </div>
  159. <div style={{
  160. display: 'flex',
  161. flexDirection: 'row',
  162. alignItems: 'center',
  163. justifyContent: 'center',
  164. width: '100%'
  165. }}>
  166. <input type="file" ref={inputRef} {...inputProps} />
  167. <div onClick={() => inputRef.current!.click()} onMouseOver={() => setInputHover(true)} onMouseOut={() => setInputHover(false)} style={{
  168. ...buttonStyles,
  169. backgroundColor: inputHover ? 'rgba(0, 0, 0, 0.14)' : 'white'
  170. }}>Select Files</div>
  171. {supportsInputDirs && allowDirs &&
  172. <>
  173. <div style={{ boxShadow: '1px 0 black', height: '100%' }}><span /></div>
  174. <input type="file" ref={dirInputRef} {...inputProps} />
  175. <div onClick={() => dirInputRef.current!.click()} onMouseOver={() => setDirInputHover(true)} onMouseOut={() => setDirInputHover(false)} style={{
  176. ...buttonStyles,
  177. marginLeft: '8vmin',
  178. backgroundColor: dirInputHover ? 'rgba(0, 0, 0, 0.14)' : 'white'
  179. }}>Select Folders</div>
  180. </>
  181. }
  182. </div>
  183. </div>
  184. );
  185. }
  186. export default FilePicker;