mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-16 21:50:35 +02:00
support image drop for upload
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Inset outline on the root is hidden behind a full-bleed ItemImage; a ::after layer paints
|
||||
* above the image. Keep z-index below overlay controls (e.g. z-index: 2).
|
||||
* Avoid positive outline-offset so ancestors with overflow:hidden do not clip the indicator.
|
||||
*/
|
||||
.file-target-drag-over {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-target-drag-over::after {
|
||||
position: absolute;
|
||||
inset: calc(var(--theme-spacing-sm) * -1);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
border-radius: var(--theme-radius-md);
|
||||
box-shadow: inset 0 0 0 3px var(--theme-colors-primary);
|
||||
}
|
||||
@@ -1,17 +1,38 @@
|
||||
import type { ChangeEvent, DragEvent, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import styles from './drag-drop-zone.module.css';
|
||||
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { isNativeFileDrag, pickFirstImageFile } from '/@/shared/utils/image-drop';
|
||||
|
||||
interface DragDropZoneProps {
|
||||
export interface DragDropZoneFileProps extends DivProps {
|
||||
accept?: string;
|
||||
children: ReactNode;
|
||||
mode: 'file';
|
||||
onFileSelected: (file: File) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type DragDropZoneProps = DragDropZoneFileProps | DragDropZoneTextProps;
|
||||
|
||||
type DivProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children' | 'onDragEnter' | 'onDragLeave' | 'onDragOver' | 'onDrop'
|
||||
>;
|
||||
|
||||
interface DragDropZoneTextProps {
|
||||
icon: keyof typeof AppIcon;
|
||||
mode?: 'text';
|
||||
onItemSelected: (contents: string) => void;
|
||||
validateItem?: (contents: string) => { error?: string; isValid: boolean };
|
||||
}
|
||||
|
||||
export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {
|
||||
const DragDropZoneText = ({ icon, onItemSelected, validateItem }: DragDropZoneTextProps) => {
|
||||
const zoneFileInput = useRef<HTMLInputElement | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
@@ -32,7 +53,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
);
|
||||
|
||||
const onItemDropped = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const items = event.dataTransfer.items;
|
||||
@@ -62,7 +83,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
[processItem],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
@@ -72,7 +93,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
}, []);
|
||||
|
||||
const onZoneInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = event.target;
|
||||
|
||||
if (!files || files.length > 1) {
|
||||
@@ -131,3 +152,83 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const DragDropZoneFile = (props: DragDropZoneFileProps) => {
|
||||
const { accept = 'image/*', children, className, mode, onFileSelected, ...divProps } = props;
|
||||
void mode;
|
||||
const fileDragDepth = useRef(0);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
|
||||
const resolveFile = useCallback(
|
||||
(dataTransfer: DataTransfer): File | null => {
|
||||
if (accept === 'image/*') {
|
||||
return pickFirstImageFile(dataTransfer.files);
|
||||
}
|
||||
const first = dataTransfer.files?.item(0);
|
||||
return first ?? null;
|
||||
},
|
||||
[accept],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileDragDepth.current += 1;
|
||||
setFileDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileDragDepth.current -= 1;
|
||||
if (fileDragDepth.current <= 0) {
|
||||
fileDragDepth.current = 0;
|
||||
setFileDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileDragDepth.current = 0;
|
||||
setFileDragOver(false);
|
||||
const file = resolveFile(e.dataTransfer);
|
||||
if (file) void onFileSelected(file);
|
||||
},
|
||||
[onFileSelected, resolveFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={clsx(className, {
|
||||
[styles.fileTargetDragOver]: fileDragOver,
|
||||
})}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DragDropZone = (props: DragDropZoneProps) => {
|
||||
if (props.mode === 'file') {
|
||||
return <DragDropZoneFile {...props} />;
|
||||
}
|
||||
|
||||
return <DragDropZoneText {...props} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
// OS / native file drag (vs in-app library drag).
|
||||
export function isNativeFileDrag(event: DragEvent): boolean {
|
||||
return event.dataTransfer.types.includes('Files');
|
||||
}
|
||||
|
||||
// First file in the list whose MIME type is an image.
|
||||
export function pickFirstImageFile(files: FileList | null): File | null {
|
||||
if (!files?.length) return null;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files.item(i);
|
||||
if (f?.type.startsWith('image/')) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user