Merge branch 'feature/file-manager-image-map' into 'main'
Feature/add image map editor See merge request crux/admin!26
This commit is contained in:
@@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added image map editor for creating and editing interactive image map areas in the File Manager.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored admin module-generator source builders to compose imports and source sections through
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { FolderOpen, Wrench } from 'lucide-react';
|
||||
import { IntlShape } from 'react-intl';
|
||||
import { MenuItem } from '#app/config/types';
|
||||
|
||||
@@ -12,9 +12,20 @@ export const createFileManagerMenu = (intl: IntlShape): MenuItem => ({
|
||||
permissions: ['file_manager.view']
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.TITLE' }),
|
||||
path: '/file-manager/editor/image/-/',
|
||||
permissions: ['file_manager.view']
|
||||
title: intl.formatMessage({ id: 'FILE_MANAGER.TOOLS.TITLE' }),
|
||||
icon: Wrench,
|
||||
children: [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.TITLE' }),
|
||||
path: '/file-manager/tools/image-editor/-/',
|
||||
permissions: ['file_manager.view']
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_MAP.TITLE' }),
|
||||
path: '/file-manager/tools/image-map/-/',
|
||||
permissions: ['file_manager.view']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'FILE_MANAGER.SETTINGS' }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import DriveExplorerPage from '../pages/DriveExplorerPage';
|
||||
import DriveSettingsPage from '../pages/DriveSettingsPage';
|
||||
import DriveViewerPage from '../pages/DriveViewerPage';
|
||||
import ImageEditorPage from '../pages/ImageEditorPage';
|
||||
import ImageMapPage from '../pages/ImageMapPage';
|
||||
|
||||
function Routing() {
|
||||
const navigate = useNavigate();
|
||||
@@ -19,7 +20,8 @@ function Routing() {
|
||||
<Route path="settings" element={<DriveSettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="editor/image/-/*" element={<ImageEditorPage />} />
|
||||
<Route path="tools/image-editor/-/*" element={<ImageEditorPage />} />
|
||||
<Route path="tools/image-map/-/*" element={<ImageMapPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<ResourceNotFound title="Page not found" onBack={() => navigate(-1)} />}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"FILE_MANAGER.MENU.TITLE": "File Manager",
|
||||
"FILE_MANAGER.TOOLS.TITLE": "Tools",
|
||||
"FILE_MANAGER.SETTINGS": "Settings",
|
||||
"FILE_MANAGER.SETTINGS.TITLE": "File Manager Settings",
|
||||
"FILE_MANAGER.SETTINGS.DESCRIPTION": "Customize your workspace to better manage and navigate your files. Adjust how your items are displayed, change icon dimensions, and optimize the spacing for your specific screen size.",
|
||||
@@ -53,6 +54,7 @@
|
||||
"FILE_MANAGER.DRIVE.ACTION.BACK": "Back",
|
||||
"FILE_MANAGER.DRIVE.ACTION.CROP": "Crop",
|
||||
"FILE_MANAGER.DRIVE.ACTION.RESIZE": "Resize",
|
||||
"FILE_MANAGER.DRIVE.ACTION.MAP": "Map",
|
||||
"FILE_MANAGER.DRIVE.ACTION.CANCEL": "Cancel",
|
||||
"FILE_MANAGER.DRIVE.ACTION.CREATE": "Create",
|
||||
"FILE_MANAGER.DRIVE.ACTION.SUBMIT": "Submit",
|
||||
@@ -124,5 +126,21 @@
|
||||
"FILE_MANAGER.FILE_DETAILS.SIZE": "Size",
|
||||
"FILE_MANAGER.FILE_DETAILS.FILE_NAME": "Name",
|
||||
"FILE_MANAGER.FILE_DETAILS.LAST_MODIFIED": "Last modified",
|
||||
"FILE_MANAGER.FILE_DETAILS.URL": "URL"
|
||||
"FILE_MANAGER.FILE_DETAILS.URL": "URL",
|
||||
|
||||
"FILE_MANAGER.IMAGE_MAP.TITLE": "Image Map",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.TITLE": "Areas",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.ITEM": "Area",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.ADD": "Add area",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.REMOVE": "Remove area",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE": "Selected area",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.SHAPE": "Shape",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.URL": "URL",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE_FIELD": "Title",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE_PLACEHOLDER": "Area title",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TARGET": "Target",
|
||||
"FILE_MANAGER.IMAGE_MAP.CODE_PREVIEW.TITLE": "Code preview",
|
||||
"FILE_MANAGER.IMAGE_MAP.SHAPE.RECTANGLE": "Rectangle",
|
||||
"FILE_MANAGER.IMAGE_MAP.SHAPE.CIRCLE": "Circle",
|
||||
"FILE_MANAGER.IMAGE_MAP.SHAPE.POLYGON": "Polygon"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"FILE_MANAGER.MENU.TITLE": "Файловий Менеджер",
|
||||
"FILE_MANAGER.TOOLS.TITLE": "Інструменти",
|
||||
"FILE_MANAGER.SETTINGS": "Налаштування",
|
||||
"FILE_MANAGER.SETTINGS.TITLE": "Налаштування Файлового Менеджера",
|
||||
"FILE_MANAGER.SETTINGS.DESCRIPTION": "Налаштуйте своє робоче середовище для кращого керування файлами та навігації між ними. Налаштуйте відображення елементів, змініть розміри значків та оптимізуйте інтервали відповідно до розміру екрана.",
|
||||
@@ -53,6 +54,7 @@
|
||||
"FILE_MANAGER.DRIVE.ACTION.BACK": "Назад",
|
||||
"FILE_MANAGER.DRIVE.ACTION.CROP": "Обрізати",
|
||||
"FILE_MANAGER.DRIVE.ACTION.RESIZE": "Змінити розмір",
|
||||
"FILE_MANAGER.DRIVE.ACTION.MAP": "Розмітити",
|
||||
"FILE_MANAGER.DRIVE.ACTION.CANCEL": "Відмінити",
|
||||
"FILE_MANAGER.DRIVE.ACTION.CREATE": "Створити",
|
||||
"FILE_MANAGER.DRIVE.ACTION.SUBMIT": "Підтвердити",
|
||||
@@ -124,5 +126,21 @@
|
||||
"FILE_MANAGER.FILE_DETAILS.SIZE": "Розмір",
|
||||
"FILE_MANAGER.FILE_DETAILS.FILE_NAME": "Назва",
|
||||
"FILE_MANAGER.FILE_DETAILS.LAST_MODIFIED": "Змінено",
|
||||
"FILE_MANAGER.FILE_DETAILS.URL": "URL"
|
||||
"FILE_MANAGER.FILE_DETAILS.URL": "URL",
|
||||
|
||||
"FILE_MANAGER.IMAGE_MAP.TITLE": "Розмітка зображень",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.TITLE": "Області",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.ITEM": "Область",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.ADD": "Додати область",
|
||||
"FILE_MANAGER.IMAGE_MAP.AREAS.REMOVE": "Видалити область",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE": "Вибрана область",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.SHAPE": "Форма",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.URL": "URL",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE_FIELD": "Назва",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE_PLACEHOLDER": "Назва області",
|
||||
"FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TARGET": "target",
|
||||
"FILE_MANAGER.IMAGE_MAP.CODE_PREVIEW.TITLE": "Попередній перегляд коду",
|
||||
"FILE_MANAGER.IMAGE_MAP.SHAPE.RECTANGLE": "Прямокутник",
|
||||
"FILE_MANAGER.IMAGE_MAP.SHAPE.CIRCLE": "Коло",
|
||||
"FILE_MANAGER.IMAGE_MAP.SHAPE.POLYGON": "Полігон"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Crop, Scaling } from 'lucide-react';
|
||||
import { Crop, MousePointerSquareDashed, Scaling } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { driveImageEditorPath } from '#core/FileManager/shared/routing/driveLinks';
|
||||
import { driveImageEditorPath, driveImageMapPath } from '#core/FileManager/shared/routing/driveLinks';
|
||||
import BackButton from '#core/FileManager/view/ui/BackButton';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import Breadcrumbs from './Breadcrumbs';
|
||||
@@ -23,6 +23,12 @@ const PageLayout: React.FC<{
|
||||
<Breadcrumbs path={path} />
|
||||
{isFileFormattable(path) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="sm" className="h-8 gap-1.5">
|
||||
<Link to={driveImageMapPath(path)}>
|
||||
<MousePointerSquareDashed className="h-4 w-4" />
|
||||
{intl.formatMessage({ id: 'FILE_MANAGER.DRIVE.ACTION.MAP' })}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm" className="h-8 gap-1.5">
|
||||
<Link to={driveImageEditorPath(path, 'resize')}>
|
||||
<Scaling className="h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import FileManagerHeader from '#core/FileManager/view/ui/FileManagerHeader';
|
||||
|
||||
const PageLayout: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FileManagerHeader title={intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_MAP.TITLE' })} />
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLayout;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getErrorMessage } from '#infrastructure/reactQuery';
|
||||
import { useGetFile } from '#core/FileManager/data/queries/file';
|
||||
import { useGetRawFile } from '#core/FileManager/data/queries/rawFile';
|
||||
import { getFilePreviewType } from '#core/FileManager/shared/file/fileHelpers';
|
||||
import ImageMap from '#core/FileManager/view/widgets/ImageMap';
|
||||
import { PermissionDenied } from '#shared/widgets/screens/PermissionDenied';
|
||||
import { ResourceNotFound } from '#shared/widgets/screens/ResourceNotFound';
|
||||
import { SomethingWentWrong } from '#shared/widgets/screens/SomethingWentWrong';
|
||||
import PageLayout from './PageLayout';
|
||||
|
||||
function isImageMapSupported(extension: string | undefined): boolean {
|
||||
const ext = (extension ?? '').replace(/^\./, '').toLowerCase();
|
||||
return getFilePreviewType(ext) === 'image' && ext !== 'svg';
|
||||
}
|
||||
|
||||
const ImageMapPage: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { '*': path = '' } = useParams();
|
||||
const hasPath = path.trim().length > 0;
|
||||
|
||||
const fileQuery = useGetFile(hasPath ? { path } : undefined);
|
||||
const file = fileQuery.data?.entry;
|
||||
const isImage = isImageMapSupported(file?.extension);
|
||||
|
||||
const rawFileQuery = useGetRawFile(hasPath ? { path } : undefined, Boolean(file) && isImage);
|
||||
|
||||
const [imageSrc, setImageSrc] = React.useState<string | undefined>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!rawFileQuery.data) {
|
||||
setImageSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(rawFileQuery.data);
|
||||
setImageSrc(objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [rawFileQuery.data]);
|
||||
|
||||
if (!hasPath) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<ImageMap />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const error = fileQuery.error ?? rawFileQuery.error;
|
||||
|
||||
if (error) {
|
||||
const status = isAxiosError(error) ? (error.response?.status ?? 0) : 0;
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
if (status === 404) {
|
||||
return <ResourceNotFound onBack={() => navigate(-1)} message={message} />;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return <PermissionDenied onBack={() => navigate(-1)} message={message} />;
|
||||
}
|
||||
|
||||
return <SomethingWentWrong onBack={() => navigate(-1)} message={message} />;
|
||||
}
|
||||
|
||||
if (fileQuery.isLoading || rawFileQuery.isLoading) return null;
|
||||
|
||||
if (!file) {
|
||||
return <SomethingWentWrong onBack={() => navigate(-1)} message="Please try again" />;
|
||||
}
|
||||
|
||||
if (!isImage) {
|
||||
return (
|
||||
<SomethingWentWrong
|
||||
onBack={() => navigate(-1)}
|
||||
title={intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.NOT_IMAGE_TITLE' })}
|
||||
message={intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.NOT_IMAGE_MESSAGE' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<ImageMap src={imageSrc} fileName={file.name} />
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageMapPage;
|
||||
@@ -10,8 +10,15 @@ export function driveFilePath(path: string): string {
|
||||
}
|
||||
|
||||
export function driveImageEditorPath(path: string, tab?: string): string {
|
||||
const target = generatePath('/file-manager/editor/image/-/*', { '*': stripLeadingSlash(path) });
|
||||
const target = generatePath('/file-manager/tools/image-editor/-/*', {
|
||||
'*': stripLeadingSlash(path)
|
||||
});
|
||||
|
||||
if (tab) return `${target}?tab=${tab}`;
|
||||
return target;
|
||||
return tab ? `${target}?tab=${tab}` : target;
|
||||
}
|
||||
|
||||
export function driveImageMapPath(path: string): string {
|
||||
return generatePath('/file-manager/tools/image-map/-/*', {
|
||||
'*': stripLeadingSlash(path)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { getAreaBoundingBox, getScaledCircleOverlayStyle, getScaledRectOverlayStyle, scaleArea } from './helpers';
|
||||
import { ImageMapArea, ImageMapCircleArea, ImageMapPolygonArea, ImageMapRectArea } from './types';
|
||||
import { CircleOverlay, PolygonOverlay, RectOverlay } from './ImageMapResizeHandles';
|
||||
|
||||
type Props = {
|
||||
src?: string;
|
||||
imgRef: React.RefObject<HTMLImageElement | null>;
|
||||
wrapperRef: React.RefObject<HTMLDivElement | null>;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
areas: ImageMapArea[];
|
||||
originalDimensions: { width: number; height: number };
|
||||
selectedArea: ImageMapArea | null;
|
||||
onImageLoad: (e: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onPointerUp: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const ImageMapCanvas: React.FC<Props> = ({
|
||||
src,
|
||||
imgRef,
|
||||
wrapperRef,
|
||||
fileInputRef,
|
||||
areas,
|
||||
originalDimensions,
|
||||
selectedArea,
|
||||
onImageLoad,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onFileSelect
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [renderedImageSize, setRenderedImageRect] = React.useState({ width: 0, height: 0 });
|
||||
|
||||
const updateRenderedImageRect = React.useCallback(() => {
|
||||
const image = imgRef.current;
|
||||
|
||||
if (!image) {
|
||||
setRenderedImageRect({ width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = image.getBoundingClientRect();
|
||||
setRenderedImageRect({
|
||||
width: Math.round(width),
|
||||
height: Math.round(height)
|
||||
});
|
||||
}, [imgRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateRenderedImageRect();
|
||||
|
||||
const image = imgRef.current;
|
||||
if (!image) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateRenderedImageRect();
|
||||
});
|
||||
|
||||
resizeObserver.observe(image);
|
||||
window.addEventListener('resize', updateRenderedImageRect);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', updateRenderedImageRect);
|
||||
};
|
||||
}, [imgRef, updateRenderedImageRect, src, originalDimensions.width, originalDimensions.height]);
|
||||
|
||||
const displayScale = React.useMemo(() => {
|
||||
if (!originalDimensions.width || !originalDimensions.height) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!renderedImageSize.width || !renderedImageSize.height) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
renderedImageSize.width / originalDimensions.width,
|
||||
renderedImageSize.height / originalDimensions.height
|
||||
);
|
||||
}, [
|
||||
originalDimensions.height,
|
||||
originalDimensions.width,
|
||||
renderedImageSize.height,
|
||||
renderedImageSize.width
|
||||
]);
|
||||
|
||||
const canvasWidth = renderedImageSize.width || originalDimensions.width;
|
||||
const canvasHeight = renderedImageSize.height || originalDimensions.height;
|
||||
|
||||
const handleImageLoad = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
onImageLoad(event);
|
||||
requestAnimationFrame(() => {
|
||||
updateRenderedImageRect();
|
||||
});
|
||||
},
|
||||
[onImageLoad, updateRenderedImageRect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-muted/30 overflow-auto">
|
||||
{!src ? (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center w-full max-w-md h-80 border-2 border-dashed border-border rounded-xl bg-card cursor-pointer hover:border-primary/50 hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground mb-1">
|
||||
{intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.UPLOAD_TITLE' })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.UPLOAD_HINT' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative inline-block max-w-full"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt="Preview"
|
||||
className="max-w-full max-h-[calc(100vh-200px)] object-contain"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0" style={{ width: canvasWidth, height: canvasHeight }}>
|
||||
{areas.map((area) => {
|
||||
if (area.shape === 'rect') {
|
||||
return (
|
||||
<RectOverlay
|
||||
key={area.id}
|
||||
area={scaleArea(area, displayScale) as ImageMapRectArea}
|
||||
isSelected={area.id === selectedArea?.id}
|
||||
boundingBox={getAreaBoundingBox(area, displayScale)}
|
||||
overlayStyle={getScaledRectOverlayStyle(area, displayScale)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (area.shape === 'circle') {
|
||||
return (
|
||||
<CircleOverlay
|
||||
key={area.id}
|
||||
area={scaleArea(area, displayScale) as ImageMapCircleArea}
|
||||
isSelected={area.id === selectedArea?.id}
|
||||
boundingBox={getAreaBoundingBox(area, displayScale)}
|
||||
overlayStyle={getScaledCircleOverlayStyle(area, displayScale)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (area.shape === 'polygon') {
|
||||
return (
|
||||
<PolygonOverlay
|
||||
key={area.id}
|
||||
area={scaleArea(area, displayScale) as ImageMapPolygonArea}
|
||||
isSelected={area.id === selectedArea?.id}
|
||||
boundingBox={getAreaBoundingBox(area, displayScale)}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/webp"
|
||||
onChange={onFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageMapCanvas;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { ImageMapPolygonArea, ImageMapRectArea } from './types';
|
||||
import React from 'react';
|
||||
import { cn } from '@crux-kit/utils';
|
||||
import { ImageMapCircleArea } from './types';
|
||||
|
||||
type BoundingBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const HandleDot: React.FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
className?: string;
|
||||
}> = ({ x, y, className }) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'bg-primary absolute rounded-full size-3 border border-background shadow -translate-x-1/2 -translate-y-1/2 cursor-pointer',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AreaLabelProps = {
|
||||
boundingBox: BoundingBox | null;
|
||||
label: string;
|
||||
offset?: number;
|
||||
fontSize?: number;
|
||||
};
|
||||
export const AreaLabel: React.FC<AreaLabelProps> = ({
|
||||
boundingBox,
|
||||
label,
|
||||
offset = 22,
|
||||
fontSize = 10
|
||||
}) => {
|
||||
if (!boundingBox) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute px-2 py-1 rounded bg-black/70 text-white font-medium whitespace-nowrap"
|
||||
style={{
|
||||
left: boundingBox.x,
|
||||
top: Math.max(0, boundingBox.y - offset),
|
||||
fontSize
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CircleOverlayProps = {
|
||||
area: ImageMapCircleArea;
|
||||
isSelected: boolean;
|
||||
boundingBox: BoundingBox | null;
|
||||
overlayStyle: React.CSSProperties | null;
|
||||
};
|
||||
export const CircleOverlay: React.FC<CircleOverlayProps> = ({
|
||||
area,
|
||||
isSelected,
|
||||
boundingBox,
|
||||
overlayStyle
|
||||
}) => {
|
||||
const [centerPoint, edgePoint] = area.points;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{overlayStyle ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full border-2 bg-sky-500/10',
|
||||
isSelected ? 'border-primary' : 'border-sky-500'
|
||||
)}
|
||||
style={overlayStyle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isSelected && centerPoint ? <HandleDot x={centerPoint.x} y={centerPoint.y} /> : null}
|
||||
{isSelected && edgePoint ? <HandleDot x={edgePoint.x} y={edgePoint.y} /> : null}
|
||||
|
||||
<AreaLabel boundingBox={boundingBox} label={area.title || area.shape} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RectOverlayProps = {
|
||||
area: ImageMapRectArea;
|
||||
isSelected: boolean;
|
||||
boundingBox: BoundingBox | null;
|
||||
overlayStyle: React.CSSProperties | null;
|
||||
};
|
||||
export const RectOverlay: React.FC<RectOverlayProps> = ({
|
||||
area,
|
||||
isSelected,
|
||||
boundingBox,
|
||||
overlayStyle
|
||||
}) => {
|
||||
const [firstPoint, secondPoint] = area.points;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{overlayStyle ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute border-2 bg-sky-500/10',
|
||||
isSelected ? 'border-primary' : 'border-sky-500'
|
||||
)}
|
||||
style={overlayStyle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isSelected && firstPoint ? <HandleDot x={firstPoint.x} y={firstPoint.y} /> : null}
|
||||
{isSelected && secondPoint ? <HandleDot x={secondPoint.x} y={secondPoint.y} /> : null}
|
||||
|
||||
<AreaLabel boundingBox={boundingBox} label={area.title || area.shape} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PolygonOverlayProps = {
|
||||
area: ImageMapPolygonArea;
|
||||
isSelected: boolean;
|
||||
boundingBox: BoundingBox | null;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
};
|
||||
|
||||
export const PolygonOverlay: React.FC<PolygonOverlayProps> = ({
|
||||
area,
|
||||
isSelected,
|
||||
boundingBox,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
}) => {
|
||||
const path = React.useMemo(() => {
|
||||
if (area.points.length === 0) return '';
|
||||
|
||||
if (area.points.length === 1) {
|
||||
const [{ x, y }] = area.points;
|
||||
return `M ${x} ${y}`;
|
||||
}
|
||||
|
||||
if (area.points.length === 2) {
|
||||
const [{ x: x1, y: y1 }, { x: x2, y: y2 }] = area.points;
|
||||
return `M ${x1} ${y1} L ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
return `M ${area.points.map(({ x, y }) => `${x} ${y}`).join(' L ')} Z`;
|
||||
}, [area.points]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
viewBox={`0 0 ${canvasWidth} ${canvasHeight}`}
|
||||
>
|
||||
<path
|
||||
d={path}
|
||||
className={cn(
|
||||
'fill-sky-500/15 stroke-2',
|
||||
isSelected ? 'stroke-primary' : 'stroke-sky-500'
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{isSelected &&
|
||||
area.points.map((point, index) => (
|
||||
<HandleDot key={`${area.id}-vertex-${index}`} x={point.x} y={point.y} />
|
||||
))}
|
||||
|
||||
<AreaLabel
|
||||
boundingBox={boundingBox}
|
||||
label={area.title || area.shape}
|
||||
offset={12}
|
||||
fontSize={12}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Code2, Link2, MousePointerSquareDashed, X } from 'lucide-react';
|
||||
import { Input } from '#shared/ui/Input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#shared/ui/Select';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import { Textarea } from '#shared/ui/Textarea';
|
||||
import { ImageMapArea, ImageMapTarget } from './types';
|
||||
import { shapeOptions, targetOptions } from './config';
|
||||
import { cn } from '@crux-kit/utils';
|
||||
import { RadioGroup, RadioGroupItem } from '#shared/ui/RadioGroup';
|
||||
import { CopyButton } from '#shared/ui/CopyButton';
|
||||
|
||||
type Props = {
|
||||
hasImage: boolean;
|
||||
areas: ImageMapArea[];
|
||||
selectedArea: ImageMapArea | null;
|
||||
codePreview: string;
|
||||
onAddArea: () => void;
|
||||
onSelectArea: (id: string) => void;
|
||||
onRemoveArea: (id: string) => void;
|
||||
onUpdateArea: (updater: (area: ImageMapArea) => ImageMapArea) => void;
|
||||
};
|
||||
|
||||
const ImageMapSidebar: React.FC<Props> = ({
|
||||
hasImage,
|
||||
areas,
|
||||
selectedArea,
|
||||
codePreview,
|
||||
onAddArea,
|
||||
onSelectArea,
|
||||
onRemoveArea,
|
||||
onUpdateArea
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex w-80 flex-col overflow-hidden border-l bg-card">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
<section className="rounded-lg p-4 shadow space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MousePointerSquareDashed className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm grow font-medium">
|
||||
{intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_MAP.AREAS.TITLE' })}
|
||||
</div>
|
||||
<Button variant="primary" onClick={onAddArea} disabled={!hasImage}>
|
||||
{intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_MAP.AREAS.ADD' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{areas.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{areas.map((area, index) => (
|
||||
<div key={area.id} className="flex items-center gap-1">
|
||||
<div
|
||||
onClick={() => onSelectArea(area.id)}
|
||||
className={cn(
|
||||
'grow cursor-pointer rounded-lg border py-3 px-2 transition',
|
||||
selectedArea?.id === area.id && 'border-primary bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between min-w-0 gap-1">
|
||||
<p className="text-left text-sm font-medium line-clamp-1 max-w-38">
|
||||
{area.title ||
|
||||
`${intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.AREAS.ITEM'
|
||||
})} ${index + 1}`}
|
||||
</p>
|
||||
|
||||
{area.shape && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: shapeOptions.find((opt) => opt.value === area.shape)?.labelId
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="foreground"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive p-0"
|
||||
onClick={() => onRemoveArea(area.id)}
|
||||
title={intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.AREAS.REMOVE'
|
||||
})}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedArea && (
|
||||
<section className="rounded-lg p-4 shadow space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">
|
||||
{intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="inline-block mb-2 text-xs font-medium text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.SHAPE'
|
||||
})}
|
||||
</label>
|
||||
|
||||
<RadioGroup
|
||||
value={selectedArea.shape}
|
||||
onValueChange={(value) => {
|
||||
onUpdateArea(
|
||||
(area) =>
|
||||
({
|
||||
...area,
|
||||
shape: value,
|
||||
points: []
|
||||
}) as ImageMapArea
|
||||
);
|
||||
}}
|
||||
className="flex flex-wrap gap-4 items-center"
|
||||
>
|
||||
{shapeOptions.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
htmlFor={`shape-${option.value}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RadioGroupItem id={`shape-${option.value}`} value={option.value} />
|
||||
<span className="text-sm">{intl.formatMessage({ id: option.labelId })}</span>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.URL'
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
value={selectedArea.href}
|
||||
onChange={(e) =>
|
||||
onUpdateArea((area) => ({
|
||||
...area,
|
||||
href: e.target.value
|
||||
}))
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE_FIELD'
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
value={selectedArea.title}
|
||||
onChange={(e) =>
|
||||
onUpdateArea((area) => ({
|
||||
...area,
|
||||
title: e.target.value
|
||||
}))
|
||||
}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TITLE_PLACEHOLDER'
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.SELECTED_AREA.TARGET'
|
||||
})}
|
||||
</label>
|
||||
|
||||
<Select
|
||||
value={selectedArea.target}
|
||||
onValueChange={(value) =>
|
||||
onUpdateArea((area) => ({
|
||||
...area,
|
||||
target: value as ImageMapTarget
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{areas.length > 0 && (
|
||||
<section className="rounded-lg p-4 shadow space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">
|
||||
{intl.formatMessage({
|
||||
id: 'FILE_MANAGER.IMAGE_MAP.CODE_PREVIEW.TITLE'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<CopyButton copyText={codePreview} size="sm" />
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={codePreview}
|
||||
className="min-h-45 resize-none font-mono text-xs"
|
||||
readOnly
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageMapSidebar;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { RotateCcw, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import BackButton from '#core/FileManager/view/ui/BackButton';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
hasImage: boolean;
|
||||
canCancel: boolean;
|
||||
onReset: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const ImageMapToolbar: React.FC<Props> = ({ title, hasImage, canCancel, onReset, onCancel }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="border-b bg-card px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<BackButton />
|
||||
<h2 className="max-w-md font-bold line-clamp-1">{title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={onReset}
|
||||
disabled={!hasImage}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{intl.formatMessage({ id: 'FILE_MANAGER.DRIVE.ACTION.RESET_TO_DEFAULT' })}
|
||||
</Button>
|
||||
{canCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-destructive hover:text-destructive"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{intl.formatMessage({ id: 'FILE_MANAGER.DRIVE.ACTION.CANCEL' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageMapToolbar;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ImageMapShape, ImageMapTarget } from './types';
|
||||
|
||||
export const targetOptions: { label: ImageMapTarget; value: ImageMapTarget }[] = [
|
||||
{ label: '_self', value: '_self' },
|
||||
{ label: '_blank', value: '_blank' },
|
||||
{ label: '_parent', value: '_parent' },
|
||||
{ label: '_top', value: '_top' }
|
||||
];
|
||||
|
||||
export const shapeOptions: { value: ImageMapShape; labelId: string }[] = [
|
||||
{ value: 'rect', labelId: 'FILE_MANAGER.IMAGE_MAP.SHAPE.RECTANGLE' },
|
||||
{ value: 'circle', labelId: 'FILE_MANAGER.IMAGE_MAP.SHAPE.CIRCLE' },
|
||||
{ value: 'polygon', labelId: 'FILE_MANAGER.IMAGE_MAP.SHAPE.POLYGON' }
|
||||
];
|
||||
@@ -0,0 +1,498 @@
|
||||
import React from 'react';
|
||||
import { CircleGeometry, ImageMapArea, ImageMapCircleArea, ImageMapPolygonArea, ImageMapRectArea, ImageSize, Point, RectGeometry, ShapePointIndex } from './types';
|
||||
|
||||
function getRectGeometry(area: ImageMapRectArea): RectGeometry | null {
|
||||
const [start, end] = area.points;
|
||||
|
||||
if (!end) return null;
|
||||
|
||||
const x = Math.min(start.x, end.x);
|
||||
const y = Math.min(start.y, end.y);
|
||||
const width = Math.abs(end.x - start.x);
|
||||
const height = Math.abs(end.y - start.y);
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
function getCircleGeometry(area: ImageMapCircleArea): CircleGeometry | null {
|
||||
const [center, edge] = area.points;
|
||||
|
||||
if (!edge) return null;
|
||||
|
||||
return {
|
||||
cx: center.x,
|
||||
cy: center.y,
|
||||
radius: getDistance(center, edge)
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getDistance(a: Point, b: Point): number {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function distanceToSegment(point: Point, start: Point, end: Point): number {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
|
||||
if (dx === 0 && dy === 0) {
|
||||
return getDistance(point, start);
|
||||
}
|
||||
|
||||
const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy);
|
||||
const clampedT = clamp(t, 0, 1);
|
||||
|
||||
const projected = {
|
||||
x: start.x + clampedT * dx,
|
||||
y: start.y + clampedT * dy
|
||||
};
|
||||
|
||||
return getDistance(point, projected);
|
||||
}
|
||||
|
||||
function isPointInPolygon(point: Point, polygon: Point[]): boolean {
|
||||
let inside = false;
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x;
|
||||
const yi = polygon[i].y;
|
||||
const xj = polygon[j].x;
|
||||
const yj = polygon[j].y;
|
||||
|
||||
const intersect =
|
||||
yi > point.y !== yj > point.y &&
|
||||
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + Number.EPSILON) + xi;
|
||||
|
||||
if (intersect) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function getAreaCoords(area: ImageMapArea): string {
|
||||
if (area.shape === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (area.shape === 'rect') {
|
||||
const rect = getRectGeometry(area);
|
||||
if (!rect) {
|
||||
return area.points.flatMap((point) => [point.x, point.y]).join(',');
|
||||
}
|
||||
|
||||
return [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height].join(',');
|
||||
}
|
||||
|
||||
if (area.shape === 'circle') {
|
||||
const circle = getCircleGeometry(area);
|
||||
if (!circle) {
|
||||
return area.points.flatMap((point) => [point.x, point.y]).join(',');
|
||||
}
|
||||
|
||||
return [Math.round(circle.cx), Math.round(circle.cy), Math.round(circle.radius)].join(',');
|
||||
}
|
||||
|
||||
return area.points.flatMap((point) => [point.x, point.y]).join(',');
|
||||
}
|
||||
|
||||
const HIT_TOLERANCE = 8;
|
||||
|
||||
export function replaceShapePoint<T extends ImageMapRectArea | ImageMapCircleArea>(
|
||||
area: T,
|
||||
pointIndex: ShapePointIndex,
|
||||
point: Point
|
||||
): T {
|
||||
const nextPoint = {
|
||||
x: Math.round(point.x),
|
||||
y: Math.round(point.y)
|
||||
};
|
||||
|
||||
if (area.points.length === 1) {
|
||||
return {
|
||||
...area,
|
||||
points: [nextPoint]
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (area.points.length >= 2) {
|
||||
const [firstPoint, secondPoint] = area.points;
|
||||
|
||||
return {
|
||||
...area,
|
||||
points: pointIndex === 0 ? [nextPoint, secondPoint] : [firstPoint, nextPoint]
|
||||
} as T;
|
||||
}
|
||||
|
||||
return area;
|
||||
}
|
||||
|
||||
export function createEmptyArea(): ImageMapArea {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
href: '',
|
||||
title: '',
|
||||
target: '_self',
|
||||
shape: null
|
||||
};
|
||||
}
|
||||
|
||||
export function isAreaComplete(area: ImageMapArea): boolean {
|
||||
if (area.shape === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (area.shape === 'rect' || area.shape === 'circle') {
|
||||
return area.points.length >= 2;
|
||||
}
|
||||
|
||||
return area.points.length > 0;
|
||||
}
|
||||
|
||||
export function appendPointToArea(area: ImageMapArea, point: Point): ImageMapArea {
|
||||
const nextPoint = {
|
||||
x: Math.round(point.x),
|
||||
y: Math.round(point.y)
|
||||
};
|
||||
|
||||
if (area.shape === null) {
|
||||
return area;
|
||||
}
|
||||
|
||||
if (area.shape === 'rect' || area.shape === 'circle') {
|
||||
const [firstPoint, secondPoint] = area.points;
|
||||
|
||||
if (!firstPoint) {
|
||||
return {
|
||||
...area,
|
||||
points: [nextPoint]
|
||||
};
|
||||
}
|
||||
|
||||
if (!secondPoint) {
|
||||
return {
|
||||
...area,
|
||||
points: [firstPoint, nextPoint]
|
||||
};
|
||||
}
|
||||
|
||||
return area;
|
||||
}
|
||||
|
||||
return {
|
||||
...area,
|
||||
points: [...area.points, nextPoint]
|
||||
};
|
||||
}
|
||||
|
||||
export function clampPointToBounds(point: Point, bounds: ImageSize): Point {
|
||||
return {
|
||||
x: Math.round(clamp(point.x, 0, bounds.width)),
|
||||
y: Math.round(clamp(point.y, 0, bounds.height))
|
||||
};
|
||||
}
|
||||
|
||||
export function clampAreaToBounds(area: ImageMapArea, bounds: ImageSize): ImageMapArea {
|
||||
if (area.shape === null) {
|
||||
return area;
|
||||
}
|
||||
|
||||
if (area.shape === 'rect' || area.shape === 'circle') {
|
||||
return {
|
||||
...area,
|
||||
points: area.points.map((point) => clampPointToBounds(point, bounds)) as
|
||||
| [Point]
|
||||
| [Point, Point]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...area,
|
||||
points: area.points.map((point) => clampPointToBounds(point, bounds))
|
||||
};
|
||||
}
|
||||
|
||||
export function moveArea(area: ImageMapArea, dx: number, dy: number): ImageMapArea {
|
||||
if (area.shape === null) {
|
||||
return area;
|
||||
}
|
||||
|
||||
if (area.shape === 'rect' || area.shape === 'circle') {
|
||||
return {
|
||||
...area,
|
||||
points: area.points.map((point) => ({
|
||||
x: Math.round(point.x + dx),
|
||||
y: Math.round(point.y + dy)
|
||||
})) as [Point] | [Point, Point]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...area,
|
||||
points: area.points.map((point) => ({
|
||||
x: Math.round(point.x + dx),
|
||||
y: Math.round(point.y + dy)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function movePolygonPoint(
|
||||
area: ImageMapPolygonArea,
|
||||
pointIndex: number,
|
||||
point: Point,
|
||||
bounds: ImageSize
|
||||
): ImageMapPolygonArea {
|
||||
return {
|
||||
...area,
|
||||
points: area.points.map((currentPoint, index) =>
|
||||
index === pointIndex ? clampPointToBounds(point, bounds) : currentPoint
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelativePoint(
|
||||
event: React.PointerEvent | React.MouseEvent,
|
||||
img: HTMLImageElement
|
||||
): Point | null {
|
||||
const rect = img.getBoundingClientRect();
|
||||
|
||||
if (!rect.width || !rect.height || !img.naturalWidth || !img.naturalHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scaleX = img.naturalWidth / rect.width;
|
||||
const scaleY = img.naturalHeight / rect.height;
|
||||
|
||||
return {
|
||||
x: Math.round((event.clientX - rect.left) * scaleX),
|
||||
y: Math.round((event.clientY - rect.top) * scaleY)
|
||||
};
|
||||
}
|
||||
|
||||
export function getPolygonVertexAtPoint(
|
||||
area: ImageMapPolygonArea,
|
||||
point: Point,
|
||||
hitRadius: number
|
||||
): number | null {
|
||||
for (let index = 0; index < area.points.length; index += 1) {
|
||||
const vertex = area.points[index];
|
||||
if (getDistance(vertex, point) <= hitRadius) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPointInsideArea(area: ImageMapArea, point: Point): boolean {
|
||||
if (area.shape === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (area.shape === 'rect') {
|
||||
const rect = getRectGeometry(area);
|
||||
if (!rect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.width &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.height
|
||||
);
|
||||
}
|
||||
|
||||
if (area.shape === 'circle') {
|
||||
const circle = getCircleGeometry(area);
|
||||
if (!circle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getDistance(point, { x: circle.cx, y: circle.cy }) <= circle.radius;
|
||||
}
|
||||
|
||||
if (area.points.length === 1) {
|
||||
return getDistance(point, area.points[0]) <= HIT_TOLERANCE;
|
||||
}
|
||||
|
||||
if (area.points.length === 2) {
|
||||
return distanceToSegment(point, area.points[0], area.points[1]) <= HIT_TOLERANCE;
|
||||
}
|
||||
|
||||
if (isPointInPolygon(point, area.points)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let index = 0; index < area.points.length; index += 1) {
|
||||
const start = area.points[index];
|
||||
const end = area.points[(index + 1) % area.points.length];
|
||||
|
||||
if (distanceToSegment(point, start, end) <= HIT_TOLERANCE) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getShapePointAtPoint(
|
||||
area: ImageMapRectArea | ImageMapCircleArea,
|
||||
point: Point,
|
||||
hitRadius: number
|
||||
): ShapePointIndex | null {
|
||||
const [firstPoint, secondPoint] = area.points;
|
||||
|
||||
if (!firstPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (getDistance(point, firstPoint) <= hitRadius) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!secondPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (getDistance(point, secondPoint) <= hitRadius) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDisplayScale(
|
||||
originalDimensions: ImageSize,
|
||||
renderedImageSize: ImageSize
|
||||
): number {
|
||||
if (!originalDimensions.width || !originalDimensions.height) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!renderedImageSize.width || !renderedImageSize.height) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
renderedImageSize.width / originalDimensions.width,
|
||||
renderedImageSize.height / originalDimensions.height
|
||||
);
|
||||
}
|
||||
|
||||
export function scalePoint(point: Point, displayScale: number): Point {
|
||||
return {
|
||||
x: point.x * displayScale,
|
||||
y: point.y * displayScale
|
||||
};
|
||||
}
|
||||
|
||||
export function scaleArea(area: ImageMapArea, displayScale: number): ImageMapArea {
|
||||
if (area.shape === null) return area;
|
||||
|
||||
return {
|
||||
...area,
|
||||
points: area.points.map((point) => scalePoint(point, displayScale))
|
||||
} as ImageMapArea;
|
||||
}
|
||||
|
||||
export function getScaledRectOverlayStyle(
|
||||
area: ImageMapRectArea,
|
||||
displayScale: number
|
||||
): React.CSSProperties | null {
|
||||
const rect = getRectGeometry(area);
|
||||
|
||||
if (!rect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${rect.x * displayScale}px`,
|
||||
top: `${rect.y * displayScale}px`,
|
||||
width: `${rect.width * displayScale}px`,
|
||||
height: `${rect.height * displayScale}px`
|
||||
};
|
||||
}
|
||||
|
||||
export function getScaledCircleOverlayStyle(
|
||||
area: ImageMapCircleArea,
|
||||
displayScale: number
|
||||
): React.CSSProperties | null {
|
||||
const circle = getCircleGeometry(area);
|
||||
|
||||
if (!circle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${(circle.cx - circle.radius) * displayScale}px`,
|
||||
top: `${(circle.cy - circle.radius) * displayScale}px`,
|
||||
width: `${circle.radius * 2 * displayScale}px`,
|
||||
height: `${circle.radius * 2 * displayScale}px`
|
||||
};
|
||||
}
|
||||
|
||||
export function getAreaBoundingBox(area: ImageMapArea, displayScale = 1) {
|
||||
if (area.shape === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (area.shape === 'rect') {
|
||||
const rect = getRectGeometry(area);
|
||||
if (!rect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: rect.x * displayScale,
|
||||
y: rect.y * displayScale,
|
||||
width: rect.width * displayScale,
|
||||
height: rect.height * displayScale
|
||||
};
|
||||
}
|
||||
|
||||
if (area.shape === 'circle') {
|
||||
const circle = getCircleGeometry(area);
|
||||
if (!circle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (circle.cx - circle.radius) * displayScale,
|
||||
y: (circle.cy - circle.radius) * displayScale,
|
||||
width: circle.radius * 2 * displayScale,
|
||||
height: circle.radius * 2 * displayScale
|
||||
};
|
||||
}
|
||||
|
||||
if (area.points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xs = area.points.map((point) => point.x);
|
||||
const ys = area.points.map((point) => point.y);
|
||||
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
|
||||
return {
|
||||
x: minX * displayScale,
|
||||
y: minY * displayScale,
|
||||
width: (maxX - minX) * displayScale,
|
||||
height: (maxY - minY) * displayScale
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import ImageMapCanvas from './ImageMapCanvas';
|
||||
import ImageMapSidebar from './ImageMapSidebar';
|
||||
import ImageMapToolbar from './ImageMapToolbar';
|
||||
import { useImageMapControl } from './useImageMapControl';
|
||||
|
||||
type ImageMapProps = {
|
||||
src?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const ImageMap: React.FC<ImageMapProps> = ({ src, fileName }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { state, actions, refs } = useImageMapControl({ src, fileName });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-background" style={{ height: 'calc(100% - 72px)' }}>
|
||||
<ImageMapToolbar
|
||||
title={state.localFileName ? state.localFileName : undefined}
|
||||
hasImage={Boolean(state.localSrc)}
|
||||
canCancel={state.canCancel}
|
||||
onReset={actions.reset}
|
||||
onCancel={actions.cancel}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 min-h-0 flex overflow-hidden">
|
||||
<ImageMapCanvas
|
||||
src={state.localSrc}
|
||||
imgRef={refs.imgRef}
|
||||
selectedArea={state.selectedArea}
|
||||
wrapperRef={refs.wrapperRef}
|
||||
originalDimensions={state.originalDimensions}
|
||||
fileInputRef={refs.fileInputRef}
|
||||
areas={state.areas}
|
||||
onImageLoad={actions.handleImageLoad}
|
||||
onPointerDown={actions.handlePointerDown}
|
||||
onPointerMove={actions.handlePointerMove}
|
||||
onPointerUp={actions.handlePointerUp}
|
||||
onFileSelect={actions.onFileSelect}
|
||||
/>
|
||||
|
||||
<ImageMapSidebar
|
||||
hasImage={Boolean(state.localSrc)}
|
||||
areas={state.areas}
|
||||
selectedArea={state.selectedArea}
|
||||
codePreview={state.codePreview}
|
||||
onSelectArea={actions.selectArea}
|
||||
onAddArea={actions.addArea}
|
||||
onRemoveArea={actions.removeArea}
|
||||
onUpdateArea={actions.updateArea}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t bg-card px-4 py-1.5 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: state.localSrc
|
||||
? 'FILE_MANAGER.IMAGE_EDITOR.IMAGE_LOADED'
|
||||
: 'FILE_MANAGER.IMAGE_EDITOR.NO_IMAGE'
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{state.originalDimensions.width > 0 && (
|
||||
<span>
|
||||
{state.originalDimensions.width} x {state.originalDimensions.height} px
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageMap;
|
||||
@@ -0,0 +1,77 @@
|
||||
export type ImageMapShape = 'rect' | 'circle' | 'polygon';
|
||||
|
||||
export type ImageMapTarget = '_self' | '_blank' | '_parent' | '_top';
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type ImageMapAreaBase = {
|
||||
id: string;
|
||||
href: string;
|
||||
title: string;
|
||||
target: ImageMapTarget;
|
||||
};
|
||||
|
||||
export type EmptyImageMapArea = ImageMapAreaBase & {
|
||||
shape: null;
|
||||
};
|
||||
|
||||
export type ImageMapRectArea = ImageMapAreaBase & {
|
||||
shape: 'rect';
|
||||
points: [Point] | [Point, Point];
|
||||
};
|
||||
|
||||
export type ImageMapCircleArea = ImageMapAreaBase & {
|
||||
shape: 'circle';
|
||||
points: [Point] | [Point, Point];
|
||||
};
|
||||
|
||||
export type ImageMapPolygonArea = ImageMapAreaBase & {
|
||||
shape: 'polygon';
|
||||
points: Point[];
|
||||
};
|
||||
|
||||
export type ImageMapArea =
|
||||
| EmptyImageMapArea
|
||||
| ImageMapRectArea
|
||||
| ImageMapCircleArea
|
||||
| ImageMapPolygonArea;
|
||||
|
||||
export type ShapePointIndex = 0 | 1;
|
||||
|
||||
export type InteractionState =
|
||||
| { type: 'idle' }
|
||||
| { type: 'moving-area'; areaId: string; start: Point; snapshot: ImageMapArea }
|
||||
| { type: 'moving-polygon-point'; areaId: string; pointIndex: number }
|
||||
| {
|
||||
type: 'moving-rect-point';
|
||||
areaId: string;
|
||||
pointIndex: ShapePointIndex;
|
||||
snapshot: ImageMapRectArea;
|
||||
}
|
||||
| {
|
||||
type: 'moving-circle-point';
|
||||
areaId: string;
|
||||
pointIndex: ShapePointIndex;
|
||||
snapshot: ImageMapCircleArea;
|
||||
};
|
||||
|
||||
export type RectGeometry = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type CircleGeometry = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
export type ImageSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
import React from 'react';
|
||||
import { appendPointToArea, clampAreaToBounds, clampPointToBounds, createEmptyArea, getDisplayScale, getPolygonVertexAtPoint, getRelativePoint, getShapePointAtPoint, isAreaComplete, isPointInsideArea, moveArea, movePolygonPoint, replaceShapePoint } from './helpers';
|
||||
import { ImageMapPolygonArea, ImageMapRectArea, ImageMapCircleArea, ImageMapArea } from './types';
|
||||
import useImageMapState from './useImageMapState';
|
||||
|
||||
export type Params = {
|
||||
src?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export function useImageMapControl({ src, fileName }: Params) {
|
||||
const imgRef = React.useRef<HTMLImageElement | null>(null);
|
||||
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { state, selectedArea, codePreview, dispatch } = useImageMapState({
|
||||
src,
|
||||
fileName
|
||||
});
|
||||
|
||||
const { localSrc, localFileName, areas, interaction, originalDimensions } = state;
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
dispatch({ type: 'RESET' });
|
||||
}, [dispatch]);
|
||||
|
||||
const cancel = React.useCallback(() => {
|
||||
dispatch({ type: 'CLEAR' });
|
||||
}, [dispatch]);
|
||||
|
||||
const selectArea = React.useCallback(
|
||||
(areaId: string | null) => {
|
||||
dispatch({ type: 'SELECT_AREA', areaId });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const addArea = React.useCallback(() => {
|
||||
const area = createEmptyArea();
|
||||
dispatch({ type: 'ADD_AREA', area });
|
||||
}, [dispatch]);
|
||||
|
||||
const removeArea = React.useCallback(
|
||||
(areaId: string) => {
|
||||
dispatch({ type: 'REMOVE_AREA', areaId });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const updateArea = React.useCallback(
|
||||
(updater: (area: ImageMapArea) => ImageMapArea) => {
|
||||
dispatch({ type: 'UPDATE_AREA', updater });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleImageLoad = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = event.currentTarget;
|
||||
|
||||
dispatch({
|
||||
type: 'IMAGE_LOADED',
|
||||
dimensions: {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
}
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onFileSelect = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
dispatch({
|
||||
type: 'SET_IMAGE',
|
||||
src: objectUrl,
|
||||
fileName: file.name
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
const img = imgRef.current;
|
||||
|
||||
if (!img || !selectedArea || selectedArea.shape === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = img.getBoundingClientRect();
|
||||
const displayScale = getDisplayScale(originalDimensions, {
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
});
|
||||
const safeDisplayScale = displayScale > 0 ? displayScale : 1;
|
||||
const hitRadius = 24 / safeDisplayScale;
|
||||
|
||||
const point = getRelativePoint(event, img);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedPoint = clampPointToBounds(point, originalDimensions);
|
||||
const isComplete = isAreaComplete(selectedArea);
|
||||
const isInsideSelectedArea = isPointInsideArea(selectedArea, boundedPoint);
|
||||
|
||||
const startMovingArea = () => {
|
||||
dispatch({
|
||||
type: 'SET_INTERACTION',
|
||||
interaction: {
|
||||
type: 'moving-area',
|
||||
areaId: selectedArea.id,
|
||||
start: boundedPoint,
|
||||
snapshot: selectedArea
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const appendPointToSelectedArea = () => {
|
||||
updateArea((area) => {
|
||||
if (area.id !== selectedArea.id) {
|
||||
return area;
|
||||
}
|
||||
|
||||
return clampAreaToBounds(appendPointToArea(area, boundedPoint), originalDimensions);
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedArea.shape === 'polygon') {
|
||||
const vertexIndex = getPolygonVertexAtPoint(selectedArea, boundedPoint, hitRadius);
|
||||
|
||||
if (vertexIndex !== null) {
|
||||
dispatch({
|
||||
type: 'SET_INTERACTION',
|
||||
interaction: {
|
||||
type: 'moving-polygon-point',
|
||||
areaId: selectedArea.id,
|
||||
pointIndex: vertexIndex
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInsideSelectedArea && isComplete) {
|
||||
startMovingArea();
|
||||
return;
|
||||
}
|
||||
|
||||
appendPointToSelectedArea();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedArea.shape === 'rect') {
|
||||
const pointIndex = getShapePointAtPoint(selectedArea, boundedPoint, hitRadius);
|
||||
|
||||
if (pointIndex !== null) {
|
||||
dispatch({
|
||||
type: 'SET_INTERACTION',
|
||||
interaction: {
|
||||
type: 'moving-rect-point',
|
||||
areaId: selectedArea.id,
|
||||
pointIndex,
|
||||
snapshot: selectedArea as ImageMapRectArea
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedArea.shape === 'circle') {
|
||||
const pointIndex = getShapePointAtPoint(selectedArea, boundedPoint, hitRadius);
|
||||
|
||||
if (pointIndex !== null) {
|
||||
dispatch({
|
||||
type: 'SET_INTERACTION',
|
||||
interaction: {
|
||||
type: 'moving-circle-point',
|
||||
areaId: selectedArea.id,
|
||||
pointIndex,
|
||||
snapshot: selectedArea as ImageMapCircleArea
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isInsideSelectedArea && isComplete) {
|
||||
startMovingArea();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
appendPointToSelectedArea();
|
||||
}
|
||||
},
|
||||
[dispatch, imgRef, originalDimensions, selectedArea, updateArea]
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
const img = imgRef.current;
|
||||
|
||||
if (!img || interaction.type === 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = getRelativePoint(event, img);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedPoint = clampPointToBounds(point, originalDimensions);
|
||||
|
||||
switch (interaction.type) {
|
||||
case 'moving-polygon-point':
|
||||
updateArea((area) => {
|
||||
if (area.id !== interaction.areaId || area.shape !== 'polygon') {
|
||||
return area;
|
||||
}
|
||||
|
||||
return movePolygonPoint(
|
||||
area as ImageMapPolygonArea,
|
||||
interaction.pointIndex,
|
||||
boundedPoint,
|
||||
originalDimensions
|
||||
);
|
||||
});
|
||||
return;
|
||||
|
||||
case 'moving-area': {
|
||||
const dx = boundedPoint.x - interaction.start.x;
|
||||
const dy = boundedPoint.y - interaction.start.y;
|
||||
|
||||
updateArea((area) => {
|
||||
if (area.id !== interaction.areaId) {
|
||||
return area;
|
||||
}
|
||||
|
||||
return clampAreaToBounds(moveArea(interaction.snapshot, dx, dy), originalDimensions);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case 'moving-rect-point':
|
||||
updateArea((area) => {
|
||||
if (area.id !== interaction.areaId || area.shape !== 'rect') {
|
||||
return area;
|
||||
}
|
||||
|
||||
return clampAreaToBounds(
|
||||
replaceShapePoint(area as ImageMapRectArea, interaction.pointIndex, boundedPoint),
|
||||
originalDimensions
|
||||
) as ImageMapRectArea;
|
||||
});
|
||||
return;
|
||||
|
||||
case 'moving-circle-point':
|
||||
updateArea((area) => {
|
||||
if (area.id !== interaction.areaId || area.shape !== 'circle') {
|
||||
return area;
|
||||
}
|
||||
|
||||
return clampAreaToBounds(
|
||||
replaceShapePoint(area as ImageMapCircleArea, interaction.pointIndex, boundedPoint),
|
||||
originalDimensions
|
||||
) as ImageMapCircleArea;
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[imgRef, interaction, originalDimensions, updateArea]
|
||||
);
|
||||
|
||||
const handlePointerUp = React.useCallback(() => {
|
||||
dispatch({ type: 'SET_INTERACTION', interaction: { type: 'idle' } });
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
refs: {
|
||||
imgRef,
|
||||
wrapperRef,
|
||||
fileInputRef
|
||||
},
|
||||
state: {
|
||||
localSrc,
|
||||
localFileName,
|
||||
areas,
|
||||
selectedArea,
|
||||
interaction,
|
||||
originalDimensions,
|
||||
codePreview,
|
||||
canCancel: !src && !!localSrc
|
||||
},
|
||||
actions: {
|
||||
reset,
|
||||
cancel,
|
||||
handleImageLoad,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
onFileSelect,
|
||||
selectArea,
|
||||
removeArea,
|
||||
addArea,
|
||||
updateArea
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import { ImageSize, ImageMapArea, InteractionState } from './types';
|
||||
import { getAreaCoords } from './helpers';
|
||||
|
||||
type State = {
|
||||
localSrc?: string;
|
||||
localFileName?: string;
|
||||
areas: ImageMapArea[];
|
||||
selectedAreaId: string | null;
|
||||
interaction: InteractionState;
|
||||
originalDimensions: ImageSize;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: 'RESET' }
|
||||
| { type: 'CLEAR' }
|
||||
| { type: 'ADD_AREA'; area: ImageMapArea }
|
||||
| { type: 'REMOVE_AREA'; areaId: string }
|
||||
| { type: 'SELECT_AREA'; areaId: string | null }
|
||||
| { type: 'UPDATE_AREA'; updater: (area: ImageMapArea) => ImageMapArea }
|
||||
| { type: 'SYNC_SRC'; src?: string }
|
||||
| { type: 'SYNC_FILE_NAME'; fileName?: string }
|
||||
| { type: 'SET_IMAGE'; src: string; fileName: string }
|
||||
| { type: 'IMAGE_LOADED'; dimensions: ImageSize }
|
||||
| { type: 'SET_INTERACTION'; interaction: InteractionState };
|
||||
|
||||
const initialState: State = {
|
||||
localSrc: undefined,
|
||||
localFileName: undefined,
|
||||
areas: [],
|
||||
selectedAreaId: null,
|
||||
interaction: { type: 'idle' },
|
||||
originalDimensions: {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return {
|
||||
...state,
|
||||
areas: [],
|
||||
selectedAreaId: null,
|
||||
interaction: { type: 'idle' }
|
||||
};
|
||||
|
||||
case 'CLEAR':
|
||||
return {
|
||||
...initialState
|
||||
};
|
||||
case 'ADD_AREA':
|
||||
return {
|
||||
...state,
|
||||
areas: [...state.areas, action.area],
|
||||
selectedAreaId: action.area.id,
|
||||
interaction: { type: 'idle' }
|
||||
};
|
||||
|
||||
case 'REMOVE_AREA':
|
||||
return {
|
||||
...state,
|
||||
areas: state.areas.filter((area) => area.id !== action.areaId),
|
||||
selectedAreaId: state.selectedAreaId === action.areaId ? null : state.selectedAreaId,
|
||||
interaction:
|
||||
'areaId' in state.interaction && state.interaction.areaId === action.areaId
|
||||
? { type: 'idle' }
|
||||
: state.interaction
|
||||
};
|
||||
|
||||
case 'SELECT_AREA':
|
||||
return {
|
||||
...state,
|
||||
selectedAreaId: action.areaId
|
||||
};
|
||||
|
||||
case 'UPDATE_AREA':
|
||||
if (!state.selectedAreaId) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
areas: state.areas.map((area) =>
|
||||
area.id === state.selectedAreaId ? action.updater(area) : area
|
||||
)
|
||||
};
|
||||
|
||||
case 'SYNC_SRC':
|
||||
return {
|
||||
...state,
|
||||
localSrc: action.src
|
||||
};
|
||||
|
||||
case 'SYNC_FILE_NAME':
|
||||
return {
|
||||
...state,
|
||||
localFileName: action.fileName
|
||||
};
|
||||
|
||||
case 'SET_IMAGE':
|
||||
return {
|
||||
...state,
|
||||
localSrc: action.src,
|
||||
localFileName: action.fileName,
|
||||
areas: [],
|
||||
selectedAreaId: null,
|
||||
interaction: { type: 'idle' },
|
||||
originalDimensions: {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
};
|
||||
|
||||
case 'IMAGE_LOADED':
|
||||
return {
|
||||
...state,
|
||||
originalDimensions: action.dimensions
|
||||
};
|
||||
|
||||
case 'SET_INTERACTION':
|
||||
return {
|
||||
...state,
|
||||
interaction: action.interaction
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
type UseImageMapStateParams = {
|
||||
src?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export default function useImageMapState({ src, fileName }: UseImageMapStateParams) {
|
||||
const [state, dispatch] = React.useReducer(reducer, {
|
||||
...initialState,
|
||||
localSrc: src,
|
||||
localFileName: fileName
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch({ type: 'SYNC_SRC', src });
|
||||
}, [src]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch({ type: 'SYNC_FILE_NAME', fileName });
|
||||
}, [fileName]);
|
||||
|
||||
const selectedArea = React.useMemo(
|
||||
() => state.areas.find((area) => area.id === state.selectedAreaId) ?? null,
|
||||
[state.areas, state.selectedAreaId]
|
||||
);
|
||||
|
||||
const codePreview = React.useMemo(() => {
|
||||
const rows = state.areas.map((area) => {
|
||||
const href = area.href?.trim() ?? '';
|
||||
const title = area.title?.trim() ?? '';
|
||||
const target = area.target ?? '';
|
||||
const coords = getAreaCoords(area);
|
||||
|
||||
return ` <area target="${target}" alt="${title}" title="${title}" href="${href}" coords="${coords}" shape="${area.shape}">`;
|
||||
});
|
||||
|
||||
return [
|
||||
`<img src="${state.localFileName ?? ''}" usemap="#image-map">`,
|
||||
'',
|
||||
'<map name="image-map">',
|
||||
...rows,
|
||||
'</map>'
|
||||
].join('\n');
|
||||
}, [state.areas, state.localFileName]);
|
||||
|
||||
return {
|
||||
state,
|
||||
selectedArea,
|
||||
codePreview,
|
||||
dispatch
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# File Manager Module
|
||||
|
||||
The File Manager module provides authenticated file browsing and file operations for the admin app. It has a backend API for storage operations and a frontend UI for browsing, previewing, uploading, renaming, deleting, duplicating, and editing supported files.
|
||||
The File Manager module provides authenticated file browsing and file operations for the admin app.
|
||||
It has a backend API for storage operations and a frontend UI for browsing, previewing, uploading,
|
||||
renaming, deleting, duplicating, and editing supported files.
|
||||
|
||||
## Source Map
|
||||
|
||||
@@ -31,6 +33,7 @@ The File Manager module provides authenticated file browsing and file operations
|
||||
- Rename, delete, and duplicate entries.
|
||||
- Search entries.
|
||||
- Open an image editor for image files.
|
||||
- Create and edit image map zones for supported image files.
|
||||
- Pick files through reusable frontend picker components.
|
||||
- Insert files into RichText editor through reusable frontend picker components.
|
||||
- Use local filesystem storage or S3-compatible storage through Adonis Drive.
|
||||
@@ -72,7 +75,8 @@ type FolderEntry = {
|
||||
};
|
||||
```
|
||||
|
||||
The frontend maps DTOs into frontend entities and adds UI-oriented metadata such as `parent` and `extension`.
|
||||
The frontend maps DTOs into frontend entities and adds UI-oriented metadata such as `parent` and
|
||||
`extension`.
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
@@ -80,7 +84,8 @@ The frontend maps DTOs into frontend entities and adds UI-oriented metadata such
|
||||
- Frontend owns navigation, selection, view settings, operation progress, and user interactions.
|
||||
- React Query owns server state in the frontend.
|
||||
- MobX stores own local UI and operation state in the frontend.
|
||||
- Storage adapters must implement `storage_service_interface.ts`; callers should depend on that interface, not a concrete disk implementation.
|
||||
- Storage adapters must implement `storage_service_interface.ts`; callers should depend on that
|
||||
interface, not a concrete disk implementation.
|
||||
- The active storage adapter is selected by `DRIVE_DISK`.
|
||||
|
||||
## Common Change Guide
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# File Manager Frontend
|
||||
|
||||
The frontend module implements the admin file manager UI. It provides file browsing, previews, uploads, deletion, duplication, renaming, image editing, settings, and reusable file picker widgets.
|
||||
The frontend module implements the admin file manager UI. It provides file browsing, previews,
|
||||
uploads, deletion, duplication, renaming, image editing tools, image map tools, settings, and
|
||||
reusable file picker widgets.
|
||||
|
||||
This module follows the general [React frontend architecture](../../apps-architecture/react-frontend/Readme.md) inside its own module boundary.
|
||||
This module follows the general
|
||||
[React frontend architecture](../../apps-architecture/react-frontend/Readme.md) inside its own
|
||||
module boundary.
|
||||
|
||||
Source root:
|
||||
|
||||
@@ -66,12 +70,13 @@ Mounted module path:
|
||||
|
||||
Current child routes:
|
||||
|
||||
| Route | Page | Purpose |
|
||||
| -------------------------------- | ------------------- | ----------------------------------- |
|
||||
| `/file-manager/drive/explorer/*` | `DriveExplorerPage` | Browse folders and files. |
|
||||
| `/file-manager/drive/viewer/-/*` | `DriveViewerPage` | View file details and preview. |
|
||||
| `/file-manager/drive/settings` | `DriveSettingsPage` | Configure file-manager UI settings. |
|
||||
| `/file-manager/editor/image/-/*` | `ImageEditorPage` | Edit image files. |
|
||||
| Route | Page | Purpose |
|
||||
| -------------------------------------- | ------------------- | ------------------------------------------- |
|
||||
| `/file-manager/drive/explorer/*` | `DriveExplorerPage` | Browse folders and files. |
|
||||
| `/file-manager/drive/viewer/-/*` | `DriveViewerPage` | View file details and preview. |
|
||||
| `/file-manager/drive/settings` | `DriveSettingsPage` | Configure file-manager UI settings. |
|
||||
| `/file-manager/tools/image-editor/-/*` | `ImageEditorPage` | Edit image files. |
|
||||
| `/file-manager/tools/image-map/-/*` | `ImageMapPage` | Create and edit image map areas for images. |
|
||||
|
||||
Path helpers live in:
|
||||
|
||||
@@ -83,6 +88,19 @@ shared/path/leadingSlash.ts
|
||||
|
||||
Use these helpers instead of manually building file-manager URLs.
|
||||
|
||||
## Menu Structure
|
||||
|
||||
File Manager navigation is grouped into three top-level menu entries:
|
||||
|
||||
- `Drive` for file browsing, preview, and viewer-related flows.
|
||||
- `Tools` for standalone file tools such as image editing and image map editing.
|
||||
- `Settings` for persisted file-manager UI settings.
|
||||
|
||||
The `Tools` group currently contains:
|
||||
|
||||
- `ImageEditorPage`
|
||||
- `ImageMapPage`
|
||||
|
||||
## API Boundary
|
||||
|
||||
Raw backend calls are centralized in:
|
||||
@@ -135,11 +153,13 @@ does three things:
|
||||
2. Fetches entries with React Query.
|
||||
3. Syncs loaded entries into `EntriesStore`.
|
||||
|
||||
`EntriesStore` is not the fetch owner. It is an index of loaded entries used by the sidebar tree, selection lookups, active entry lookup, and operation state.
|
||||
`EntriesStore` is not the fetch owner. It is an index of loaded entries used by the sidebar tree,
|
||||
selection lookups, active entry lookup, and operation state.
|
||||
|
||||
### Deep Links
|
||||
|
||||
When opening a nested folder directly, `useEntries()` preloads ancestor folders through React Query. This keeps the sidebar tree and entry index usable for paths like:
|
||||
When opening a nested folder directly, `useEntries()` preloads ancestor folders through React Query.
|
||||
This keeps the sidebar tree and entry index usable for paths like:
|
||||
|
||||
```txt
|
||||
/folder/subfolder/current
|
||||
@@ -155,7 +175,8 @@ Mutations use React Query hooks:
|
||||
- `useDuplicateFiles`
|
||||
- `useUploadFile`
|
||||
|
||||
After a successful mutation, the UI updates local cache and stores directly. It does not invalidate entries queries after every operation.
|
||||
After a successful mutation, the UI updates local cache and stores directly. It does not invalidate
|
||||
entries queries after every operation.
|
||||
|
||||
This avoids unnecessary refetches when many items are created, deleted, or uploaded in sequence.
|
||||
|
||||
@@ -175,7 +196,8 @@ removeEntriesFromCache(paths);
|
||||
renameEntryInCache(prevPath, renamedEntry);
|
||||
```
|
||||
|
||||
These helpers update existing React Query cache data in memory. They do not trigger a network request.
|
||||
These helpers update existing React Query cache data in memory. They do not trigger a network
|
||||
request.
|
||||
|
||||
Important behavior:
|
||||
|
||||
@@ -184,7 +206,8 @@ Important behavior:
|
||||
- Deleted entries are removed from all cached entry pages.
|
||||
- Renamed entries are updated across cached entry pages.
|
||||
|
||||
Tradeoff: because the module avoids refetching, exact backend sort order may not be perfect after local cache insertion until the folder is naturally refetched or reloaded.
|
||||
Tradeoff: because the module avoids refetching, exact backend sort order may not be perfect after
|
||||
local cache insertion until the folder is naturally refetched or reloaded.
|
||||
|
||||
## Stores
|
||||
|
||||
@@ -303,7 +326,8 @@ Use:
|
||||
const { entriesStore, explorerStore } = useDrive();
|
||||
```
|
||||
|
||||
`SettingsProvider` owns persisted settings such as view mode, compact mode, sidebar visibility, and grid size.
|
||||
`SettingsProvider` owns persisted settings such as view mode, compact mode, sidebar visibility, and
|
||||
grid size.
|
||||
|
||||
## Pages And Widgets
|
||||
|
||||
@@ -349,6 +373,24 @@ view/widgets/ImageEditor/helpers.ts
|
||||
|
||||
This prevents `view` code from importing from `pages`.
|
||||
|
||||
### `ImageMapPage`
|
||||
|
||||
Image map page. Uses:
|
||||
|
||||
```ts
|
||||
useGetFile();
|
||||
useGetRawFile();
|
||||
```
|
||||
|
||||
Image map config and helpers live in the widget layer:
|
||||
|
||||
```txt
|
||||
view/widgets/ImageEditor/config.ts
|
||||
view/widgets/ImageEditor/helpers.ts
|
||||
```
|
||||
|
||||
This prevents `view` code from importing from `pages`.
|
||||
|
||||
### `FilePickerDialog`
|
||||
|
||||
Reusable picker widgets live in:
|
||||
@@ -357,7 +399,8 @@ Reusable picker widgets live in:
|
||||
view/widgets/FilePickerDialog/
|
||||
```
|
||||
|
||||
Keep picker behavior feature-scoped, but pass reusable UI state through props and context instead of coupling low-level UI components to page-level stores.
|
||||
Keep picker behavior feature-scoped, but pass reusable UI state through props and context instead of
|
||||
coupling low-level UI components to page-level stores.
|
||||
|
||||
## View Layer Rules
|
||||
|
||||
@@ -371,7 +414,8 @@ Lower-level reusable UI components should stay prop-driven. Examples:
|
||||
- `FileManagerFileIcon`
|
||||
- `FilePreview`
|
||||
|
||||
Do not import backend DTOs into view components. View components should consume frontend entities and props.
|
||||
Do not import backend DTOs into view components. View components should consume frontend entities
|
||||
and props.
|
||||
|
||||
## File Types And Icons
|
||||
|
||||
@@ -425,7 +469,8 @@ Do not import MobX stores or providers inside `data/queries`.
|
||||
4. Update `EntriesStore` if lookup state must change.
|
||||
5. Update `data/queries/cache.ts` helpers if visible query cache must change.
|
||||
|
||||
Do not invalidate entries queries after each operation unless a full refetch is intentionally required.
|
||||
Do not invalidate entries queries after each operation unless a full refetch is intentionally
|
||||
required.
|
||||
|
||||
## Frontend Change Checklist
|
||||
|
||||
|
||||
+3
-3
@@ -22,9 +22,9 @@
|
||||
"build:admin": "npm run build --workspace @crux-kit/admin-app",
|
||||
"build:backend": "npm run build --workspace @crux-kit/backend",
|
||||
"test:admin": "npm run test --workspace @crux-kit/admin-app",
|
||||
"patch": "npm version patch --no-git-tag-version && npm version patch --workspaces --no-git-tag-version",
|
||||
"minor": "npm version minor --no-git-tag-version && npm version minor --workspaces --no-git-tag-version",
|
||||
"major": "npm version major --no-git-tag-version && npm version major --workspaces --no-git-tag-version",
|
||||
"version:patch": "npm version patch --no-git-tag-version && npm version patch --workspaces --no-git-tag-version",
|
||||
"version:minor": "npm version minor --no-git-tag-version && npm version minor --workspaces --no-git-tag-version",
|
||||
"version:major": "npm version major --no-git-tag-version && npm version major --workspaces --no-git-tag-version",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"imports": {},
|
||||
|
||||
Reference in New Issue
Block a user