Merge branch 'feature/file-manager-image-map' into 'main'

Feature/add image map editor

See merge request crux/admin!26
This commit is contained in:
Igor Martyniuk
2026-07-10 08:47:27 +00:00
22 changed files with 2099 additions and 35 deletions
+4
View File
@@ -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
+13 -2
View File
@@ -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
};
}
+8 -3
View File
@@ -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
+58 -13
View File
@@ -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
View File
@@ -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": {},