Feature/add image map editor
This commit is contained in:
@@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added image map editor for creating and editing interactive image map areas in the File Manager.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Refactored admin module-generator source builders to compose imports and source sections through
|
- 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 { IntlShape } from 'react-intl';
|
||||||
import { MenuItem } from '#app/config/types';
|
import { MenuItem } from '#app/config/types';
|
||||||
|
|
||||||
@@ -11,11 +11,22 @@ export const createFileManagerMenu = (intl: IntlShape): MenuItem => ({
|
|||||||
path: '/file-manager/drive/explorer',
|
path: '/file-manager/drive/explorer',
|
||||||
permissions: ['file_manager.view']
|
permissions: ['file_manager.view']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'FILE_MANAGER.TOOLS.TITLE' }),
|
||||||
|
icon: Wrench,
|
||||||
|
children: [
|
||||||
{
|
{
|
||||||
title: intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.TITLE' }),
|
title: intl.formatMessage({ id: 'FILE_MANAGER.IMAGE_EDITOR.TITLE' }),
|
||||||
path: '/file-manager/editor/image/-/',
|
path: '/file-manager/tools/image-editor/-/',
|
||||||
permissions: ['file_manager.view']
|
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' }),
|
title: intl.formatMessage({ id: 'FILE_MANAGER.SETTINGS' }),
|
||||||
path: '/file-manager/drive/settings',
|
path: '/file-manager/drive/settings',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DriveExplorerPage from '../pages/DriveExplorerPage';
|
|||||||
import DriveSettingsPage from '../pages/DriveSettingsPage';
|
import DriveSettingsPage from '../pages/DriveSettingsPage';
|
||||||
import DriveViewerPage from '../pages/DriveViewerPage';
|
import DriveViewerPage from '../pages/DriveViewerPage';
|
||||||
import ImageEditorPage from '../pages/ImageEditorPage';
|
import ImageEditorPage from '../pages/ImageEditorPage';
|
||||||
|
import ImageMapPage from '../pages/ImageMapPage';
|
||||||
|
|
||||||
function Routing() {
|
function Routing() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,7 +20,8 @@ function Routing() {
|
|||||||
<Route path="settings" element={<DriveSettingsPage />} />
|
<Route path="settings" element={<DriveSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="editor/image/-/*" element={<ImageEditorPage />} />
|
<Route path="tools/image-editor/-/*" element={<ImageEditorPage />} />
|
||||||
|
<Route path="tools/image-map/-/*" element={<ImageMapPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<ResourceNotFound title="Page not found" onBack={() => navigate(-1)} />}
|
element={<ResourceNotFound title="Page not found" onBack={() => navigate(-1)} />}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"FILE_MANAGER.MENU.TITLE": "File Manager",
|
"FILE_MANAGER.MENU.TITLE": "File Manager",
|
||||||
|
"FILE_MANAGER.TOOLS.TITLE": "Tools",
|
||||||
"FILE_MANAGER.SETTINGS": "Settings",
|
"FILE_MANAGER.SETTINGS": "Settings",
|
||||||
"FILE_MANAGER.SETTINGS.TITLE": "File Manager 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.",
|
"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.BACK": "Back",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.CROP": "Crop",
|
"FILE_MANAGER.DRIVE.ACTION.CROP": "Crop",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.RESIZE": "Resize",
|
"FILE_MANAGER.DRIVE.ACTION.RESIZE": "Resize",
|
||||||
|
"FILE_MANAGER.DRIVE.ACTION.MAP": "Map",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.CANCEL": "Cancel",
|
"FILE_MANAGER.DRIVE.ACTION.CANCEL": "Cancel",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.CREATE": "Create",
|
"FILE_MANAGER.DRIVE.ACTION.CREATE": "Create",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.SUBMIT": "Submit",
|
"FILE_MANAGER.DRIVE.ACTION.SUBMIT": "Submit",
|
||||||
@@ -124,5 +126,21 @@
|
|||||||
"FILE_MANAGER.FILE_DETAILS.SIZE": "Size",
|
"FILE_MANAGER.FILE_DETAILS.SIZE": "Size",
|
||||||
"FILE_MANAGER.FILE_DETAILS.FILE_NAME": "Name",
|
"FILE_MANAGER.FILE_DETAILS.FILE_NAME": "Name",
|
||||||
"FILE_MANAGER.FILE_DETAILS.LAST_MODIFIED": "Last modified",
|
"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.MENU.TITLE": "Файловий Менеджер",
|
||||||
|
"FILE_MANAGER.TOOLS.TITLE": "Інструменти",
|
||||||
"FILE_MANAGER.SETTINGS": "Налаштування",
|
"FILE_MANAGER.SETTINGS": "Налаштування",
|
||||||
"FILE_MANAGER.SETTINGS.TITLE": "Налаштування Файлового Менеджера",
|
"FILE_MANAGER.SETTINGS.TITLE": "Налаштування Файлового Менеджера",
|
||||||
"FILE_MANAGER.SETTINGS.DESCRIPTION": "Налаштуйте своє робоче середовище для кращого керування файлами та навігації між ними. Налаштуйте відображення елементів, змініть розміри значків та оптимізуйте інтервали відповідно до розміру екрана.",
|
"FILE_MANAGER.SETTINGS.DESCRIPTION": "Налаштуйте своє робоче середовище для кращого керування файлами та навігації між ними. Налаштуйте відображення елементів, змініть розміри значків та оптимізуйте інтервали відповідно до розміру екрана.",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"FILE_MANAGER.DRIVE.ACTION.BACK": "Назад",
|
"FILE_MANAGER.DRIVE.ACTION.BACK": "Назад",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.CROP": "Обрізати",
|
"FILE_MANAGER.DRIVE.ACTION.CROP": "Обрізати",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.RESIZE": "Змінити розмір",
|
"FILE_MANAGER.DRIVE.ACTION.RESIZE": "Змінити розмір",
|
||||||
|
"FILE_MANAGER.DRIVE.ACTION.MAP": "Розмітити",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.CANCEL": "Відмінити",
|
"FILE_MANAGER.DRIVE.ACTION.CANCEL": "Відмінити",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.CREATE": "Створити",
|
"FILE_MANAGER.DRIVE.ACTION.CREATE": "Створити",
|
||||||
"FILE_MANAGER.DRIVE.ACTION.SUBMIT": "Підтвердити",
|
"FILE_MANAGER.DRIVE.ACTION.SUBMIT": "Підтвердити",
|
||||||
@@ -124,5 +126,21 @@
|
|||||||
"FILE_MANAGER.FILE_DETAILS.SIZE": "Розмір",
|
"FILE_MANAGER.FILE_DETAILS.SIZE": "Розмір",
|
||||||
"FILE_MANAGER.FILE_DETAILS.FILE_NAME": "Назва",
|
"FILE_MANAGER.FILE_DETAILS.FILE_NAME": "Назва",
|
||||||
"FILE_MANAGER.FILE_DETAILS.LAST_MODIFIED": "Змінено",
|
"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 React from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
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 BackButton from '#core/FileManager/view/ui/BackButton';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import Breadcrumbs from './Breadcrumbs';
|
import Breadcrumbs from './Breadcrumbs';
|
||||||
@@ -23,6 +23,12 @@ const PageLayout: React.FC<{
|
|||||||
<Breadcrumbs path={path} />
|
<Breadcrumbs path={path} />
|
||||||
{isFileFormattable(path) && (
|
{isFileFormattable(path) && (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<Button asChild variant="ghost" size="sm" className="h-8 gap-1.5">
|
||||||
<Link to={driveImageEditorPath(path, 'resize')}>
|
<Link to={driveImageEditorPath(path, 'resize')}>
|
||||||
<Scaling className="h-4 w-4" />
|
<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 {
|
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 tab ? `${target}?tab=${tab}` : target;
|
||||||
return 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
|
# 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
|
## Source Map
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ The File Manager module provides authenticated file browsing and file operations
|
|||||||
- Rename, delete, and duplicate entries.
|
- Rename, delete, and duplicate entries.
|
||||||
- Search entries.
|
- Search entries.
|
||||||
- Open an image editor for image files.
|
- Open an image editor for image files.
|
||||||
|
- Create and edit image map zones for supported image files.
|
||||||
- Pick files through reusable frontend picker components.
|
- Pick files through reusable frontend picker components.
|
||||||
- Insert files into RichText editor 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.
|
- 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
|
## 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.
|
- Frontend owns navigation, selection, view settings, operation progress, and user interactions.
|
||||||
- React Query owns server state in the frontend.
|
- React Query owns server state in the frontend.
|
||||||
- MobX stores own local UI and operation 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`.
|
- The active storage adapter is selected by `DRIVE_DISK`.
|
||||||
|
|
||||||
## Common Change Guide
|
## Common Change Guide
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# File Manager Frontend
|
# 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:
|
Source root:
|
||||||
|
|
||||||
@@ -67,11 +71,12 @@ Mounted module path:
|
|||||||
Current child routes:
|
Current child routes:
|
||||||
|
|
||||||
| Route | Page | Purpose |
|
| Route | Page | Purpose |
|
||||||
| -------------------------------- | ------------------- | ----------------------------------- |
|
| -------------------------------------- | ------------------- | ------------------------------------------- |
|
||||||
| `/file-manager/drive/explorer/*` | `DriveExplorerPage` | Browse folders and files. |
|
| `/file-manager/drive/explorer/*` | `DriveExplorerPage` | Browse folders and files. |
|
||||||
| `/file-manager/drive/viewer/-/*` | `DriveViewerPage` | View file details and preview. |
|
| `/file-manager/drive/viewer/-/*` | `DriveViewerPage` | View file details and preview. |
|
||||||
| `/file-manager/drive/settings` | `DriveSettingsPage` | Configure file-manager UI settings. |
|
| `/file-manager/drive/settings` | `DriveSettingsPage` | Configure file-manager UI settings. |
|
||||||
| `/file-manager/editor/image/-/*` | `ImageEditorPage` | Edit image files. |
|
| `/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:
|
Path helpers live in:
|
||||||
|
|
||||||
@@ -83,6 +88,19 @@ shared/path/leadingSlash.ts
|
|||||||
|
|
||||||
Use these helpers instead of manually building file-manager URLs.
|
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
|
## API Boundary
|
||||||
|
|
||||||
Raw backend calls are centralized in:
|
Raw backend calls are centralized in:
|
||||||
@@ -135,11 +153,13 @@ does three things:
|
|||||||
2. Fetches entries with React Query.
|
2. Fetches entries with React Query.
|
||||||
3. Syncs loaded entries into `EntriesStore`.
|
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
|
### 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
|
```txt
|
||||||
/folder/subfolder/current
|
/folder/subfolder/current
|
||||||
@@ -155,7 +175,8 @@ Mutations use React Query hooks:
|
|||||||
- `useDuplicateFiles`
|
- `useDuplicateFiles`
|
||||||
- `useUploadFile`
|
- `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.
|
This avoids unnecessary refetches when many items are created, deleted, or uploaded in sequence.
|
||||||
|
|
||||||
@@ -175,7 +196,8 @@ removeEntriesFromCache(paths);
|
|||||||
renameEntryInCache(prevPath, renamedEntry);
|
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:
|
Important behavior:
|
||||||
|
|
||||||
@@ -184,7 +206,8 @@ Important behavior:
|
|||||||
- Deleted entries are removed from all cached entry pages.
|
- Deleted entries are removed from all cached entry pages.
|
||||||
- Renamed entries are updated across 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
|
## Stores
|
||||||
|
|
||||||
@@ -303,7 +326,8 @@ Use:
|
|||||||
const { entriesStore, explorerStore } = useDrive();
|
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
|
## Pages And Widgets
|
||||||
|
|
||||||
@@ -349,6 +373,24 @@ view/widgets/ImageEditor/helpers.ts
|
|||||||
|
|
||||||
This prevents `view` code from importing from `pages`.
|
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`
|
### `FilePickerDialog`
|
||||||
|
|
||||||
Reusable picker widgets live in:
|
Reusable picker widgets live in:
|
||||||
@@ -357,7 +399,8 @@ Reusable picker widgets live in:
|
|||||||
view/widgets/FilePickerDialog/
|
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
|
## View Layer Rules
|
||||||
|
|
||||||
@@ -371,7 +414,8 @@ Lower-level reusable UI components should stay prop-driven. Examples:
|
|||||||
- `FileManagerFileIcon`
|
- `FileManagerFileIcon`
|
||||||
- `FilePreview`
|
- `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
|
## 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.
|
4. Update `EntriesStore` if lookup state must change.
|
||||||
5. Update `data/queries/cache.ts` helpers if visible query cache 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
|
## Frontend Change Checklist
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -22,9 +22,9 @@
|
|||||||
"build:admin": "npm run build --workspace @crux-kit/admin-app",
|
"build:admin": "npm run build --workspace @crux-kit/admin-app",
|
||||||
"build:backend": "npm run build --workspace @crux-kit/backend",
|
"build:backend": "npm run build --workspace @crux-kit/backend",
|
||||||
"test:admin": "npm run test --workspace @crux-kit/admin-app",
|
"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",
|
"version: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",
|
"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:major": "npm version major --no-git-tag-version && npm version major --workspaces --no-git-tag-version",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"imports": {},
|
"imports": {},
|
||||||
|
|||||||
Reference in New Issue
Block a user