Feature/add image map editor

This commit is contained in:
dmytro
2026-07-10 08:47:27 +00:00
committed by Igor Martyniuk
parent 1dde907313
commit 2232ef0029
22 changed files with 2099 additions and 35 deletions
+15 -4
View File
@@ -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
};
}