fix(Map): Add ability to copy and past systems (UI part)

This commit is contained in:
DanSylvest
2025-10-14 14:34:47 +03:00
parent 8b4e38d795
commit ee68ce92a2
13 changed files with 218 additions and 24 deletions

View File

@@ -9,6 +9,7 @@ import { useMapperHandlers } from './useMapperHandlers';
import { MapRootContent } from '@/hooks/Mapper/components/mapRootContent/MapRootContent.tsx'; import { MapRootContent } from '@/hooks/Mapper/components/mapRootContent/MapRootContent.tsx';
import { MapRootProvider } from '@/hooks/Mapper/mapRootProvider'; import { MapRootProvider } from '@/hooks/Mapper/mapRootProvider';
import './common-styles/main.scss'; import './common-styles/main.scss';
import { ToastProvider } from '@/hooks/Mapper/ToastProvider.tsx';
const ErrorFallback = () => { const ErrorFallback = () => {
return <div className="!z-100 absolute w-screen h-screen bg-transparent"></div>; return <div className="!z-100 absolute w-screen h-screen bg-transparent"></div>;
@@ -39,13 +40,15 @@ export default function MapRoot({ hooks }) {
return ( return (
<PrimeReactProvider> <PrimeReactProvider>
<MapRootProvider fwdRef={providerRef} outCommand={handleCommand}> <ToastProvider>
<ErrorBoundary FallbackComponent={ErrorFallback} onError={logError}> <MapRootProvider fwdRef={providerRef} outCommand={handleCommand}>
<ReactFlowProvider> <ErrorBoundary FallbackComponent={ErrorFallback} onError={logError}>
<MapRootContent /> <ReactFlowProvider>
</ReactFlowProvider> <MapRootContent />
</ErrorBoundary> </ReactFlowProvider>
</MapRootProvider> </ErrorBoundary>
</MapRootProvider>
</ToastProvider>
</PrimeReactProvider> </PrimeReactProvider>
); );
} }

View File

@@ -0,0 +1,31 @@
import React, { createContext, useContext, useRef } from 'react';
import { Toast } from 'primereact/toast';
import type { ToastMessage } from 'primereact/toast';
interface ToastContextValue {
toastRef: React.RefObject<Toast>;
show: (message: ToastMessage | ToastMessage[]) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const toastRef = useRef<Toast>(null);
const show = (message: ToastMessage | ToastMessage[]) => {
toastRef.current?.show(message);
};
return (
<ToastContext.Provider value={{ toastRef, show }}>
<Toast ref={toastRef} position="top-right" />
{children}
</ToastContext.Provider>
);
};
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be used within a ToastProvider');
return context;
};

View File

@@ -6,21 +6,28 @@ import { MenuItem } from 'primereact/menuitem';
export interface ContextMenuSystemMultipleProps { export interface ContextMenuSystemMultipleProps {
contextMenuRef: RefObject<ContextMenu>; contextMenuRef: RefObject<ContextMenu>;
onDeleteSystems(): void; onDeleteSystems(): void;
onCopySystems(): void;
} }
export const ContextMenuSystemMultiple: React.FC<ContextMenuSystemMultipleProps> = ({ export const ContextMenuSystemMultiple: React.FC<ContextMenuSystemMultipleProps> = ({
contextMenuRef, contextMenuRef,
onDeleteSystems, onDeleteSystems,
onCopySystems,
}) => { }) => {
const items: MenuItem[] = useMemo(() => { const items: MenuItem[] = useMemo(() => {
return [ return [
{
label: 'Copy',
icon: PrimeIcons.COPY,
command: onCopySystems,
},
{ {
label: 'Delete', label: 'Delete',
icon: PrimeIcons.TRASH, icon: PrimeIcons.TRASH,
command: onDeleteSystems, command: onDeleteSystems,
}, },
]; ];
}, [onDeleteSystems]); }, [onCopySystems, onDeleteSystems]);
return ( return (
<> <>

View File

@@ -6,27 +6,34 @@ import { ctxManager } from '@/hooks/Mapper/utils/contextManager.ts';
import { NodeSelectionMouseHandler } from '@/hooks/Mapper/components/contexts/types.ts'; import { NodeSelectionMouseHandler } from '@/hooks/Mapper/components/contexts/types.ts';
import { useDeleteSystems } from '@/hooks/Mapper/components/contexts/hooks'; import { useDeleteSystems } from '@/hooks/Mapper/components/contexts/hooks';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { encodeJsonToUriBase64 } from '@/hooks/Mapper/utils';
import { useToast } from '@/hooks/Mapper/ToastProvider.tsx';
export const useContextMenuSystemMultipleHandlers = () => { export const useContextMenuSystemMultipleHandlers = () => {
const { const {
data: { pings }, data: { pings, connections },
} = useMapRootState(); } = useMapRootState();
const { show } = useToast();
const contextMenuRef = useRef<ContextMenu | null>(null); const contextMenuRef = useRef<ContextMenu | null>(null);
const [systems, setSystems] = useState<Node<SolarSystemRawType>[]>(); const [systems, setSystems] = useState<Node<SolarSystemRawType>[]>();
const { deleteSystems } = useDeleteSystems(); const { deleteSystems } = useDeleteSystems();
const ping = useMemo(() => (pings.length === 1 ? pings[0] : undefined), [pings]); const ping = useMemo(() => (pings.length === 1 ? pings[0] : undefined), [pings]);
const refVars = useRef({ systems, ping, connections, deleteSystems });
refVars.current = { systems, ping, connections, deleteSystems };
const handleSystemMultipleContext: NodeSelectionMouseHandler = (ev, systems_) => { const handleSystemMultipleContext = useCallback<NodeSelectionMouseHandler>((ev, systems_) => {
setSystems(systems_); setSystems(systems_);
ev.preventDefault(); ev.preventDefault();
ctxManager.next('ctxSysMult', contextMenuRef.current); ctxManager.next('ctxSysMult', contextMenuRef.current);
contextMenuRef.current?.show(ev); contextMenuRef.current?.show(ev);
}; }, []);
const onDeleteSystems = useCallback(() => { const onDeleteSystems = useCallback(() => {
const { systems, ping, deleteSystems } = refVars.current;
if (!systems) { if (!systems) {
return; return;
} }
@@ -41,11 +48,34 @@ export const useContextMenuSystemMultipleHandlers = () => {
} }
deleteSystems(sysToDel); deleteSystems(sysToDel);
}, [deleteSystems, systems, ping]); }, []);
const onCopySystems = useCallback(async () => {
const { systems, connections } = refVars.current;
if (!systems) {
return;
}
const connectionToCopy = connections.filter(
c => systems.filter(s => [c.target, c.source].includes(s.id)).length == 2,
);
await navigator.clipboard.writeText(
encodeJsonToUriBase64({ systems: systems.map(x => x.data), connections: connectionToCopy }),
);
show({
severity: 'success',
summary: 'Copied to clipboard',
detail: `Successfully copied to clipboard - [${systems.length}] systems and [${connectionToCopy.length}] connections`,
life: 3000,
});
}, [show]);
return { return {
handleSystemMultipleContext, handleSystemMultipleContext,
contextMenuRef, contextMenuRef,
onDeleteSystems, onDeleteSystems,
onCopySystems,
}; };
}; };

View File

@@ -8,6 +8,4 @@ export type WaypointSetContextHandlerProps = {
destination: string; destination: string;
}; };
export type WaypointSetContextHandler = (props: WaypointSetContextHandlerProps) => void; export type WaypointSetContextHandler = (props: WaypointSetContextHandlerProps) => void;
export type NodeSelectionMouseHandler = export type NodeSelectionMouseHandler = (event: React.MouseEvent<Element, MouseEvent>, nodes: Node[]) => void;
| ((event: React.MouseEvent<Element, MouseEvent>, nodes: Node[]) => void)
| undefined;

View File

@@ -120,7 +120,7 @@ const MapComp = ({
useMapHandlers(refn, onSelectionChange); useMapHandlers(refn, onSelectionChange);
useUpdateNodes(nodes); useUpdateNodes(nodes);
const { handleRootContext, ...rootCtxProps } = useContextMenuRootHandlers({ onAddSystem }); const { handleRootContext, ...rootCtxProps } = useContextMenuRootHandlers({ onAddSystem, onCommand });
const { handleConnectionContext, ...connectionCtxProps } = useContextMenuConnectionHandlers(); const { handleConnectionContext, ...connectionCtxProps } = useContextMenuConnectionHandlers();
const { update } = useMapState(); const { update } = useMapState();
const { variant, gap, size, color } = useBackgroundVars(theme); const { variant, gap, size, color } = useBackgroundVars(theme);

View File

@@ -2,13 +2,21 @@ import React, { RefObject, useMemo } from 'react';
import { ContextMenu } from 'primereact/contextmenu'; import { ContextMenu } from 'primereact/contextmenu';
import { PrimeIcons } from 'primereact/api'; import { PrimeIcons } from 'primereact/api';
import { MenuItem } from 'primereact/menuitem'; import { MenuItem } from 'primereact/menuitem';
import { PasteSystemsAndConnections } from '@/hooks/Mapper/components/map/components';
export interface ContextMenuRootProps { export interface ContextMenuRootProps {
contextMenuRef: RefObject<ContextMenu>; contextMenuRef: RefObject<ContextMenu>;
pasteSystemsAndConnections: PasteSystemsAndConnections | undefined;
onAddSystem(): void; onAddSystem(): void;
onPasteSystemsAnsConnections(): void;
} }
export const ContextMenuRoot: React.FC<ContextMenuRootProps> = ({ contextMenuRef, onAddSystem }) => { export const ContextMenuRoot: React.FC<ContextMenuRootProps> = ({
contextMenuRef,
onAddSystem,
onPasteSystemsAnsConnections,
pasteSystemsAndConnections,
}) => {
const items: MenuItem[] = useMemo(() => { const items: MenuItem[] = useMemo(() => {
return [ return [
{ {
@@ -16,8 +24,17 @@ export const ContextMenuRoot: React.FC<ContextMenuRootProps> = ({ contextMenuRef
icon: PrimeIcons.PLUS, icon: PrimeIcons.PLUS,
command: onAddSystem, command: onAddSystem,
}, },
...(pasteSystemsAndConnections != null
? [
{
label: 'Paste',
icon: 'pi pi-clipboard',
command: onPasteSystemsAnsConnections,
},
]
: []),
]; ];
}, [onAddSystem]); }, [onAddSystem, onPasteSystemsAnsConnections, pasteSystemsAndConnections]);
return ( return (
<> <>

View File

@@ -3,34 +3,74 @@ import React, { useCallback, useRef, useState } from 'react';
import { ContextMenu } from 'primereact/contextmenu'; import { ContextMenu } from 'primereact/contextmenu';
import { ctxManager } from '@/hooks/Mapper/utils/contextManager.ts'; import { ctxManager } from '@/hooks/Mapper/utils/contextManager.ts';
import { OnMapAddSystemCallback } from '@/hooks/Mapper/components/map/map.types.ts'; import { OnMapAddSystemCallback } from '@/hooks/Mapper/components/map/map.types.ts';
import { decodeUriBase64ToJson } from '@/hooks/Mapper/utils';
import { OutCommand, OutCommandHandler, SolarSystemConnection, SolarSystemRawType } from '@/hooks/Mapper/types';
import { recenterSystemsByBounds } from '@/hooks/Mapper/helpers/recenterSystems.ts';
export type PasteSystemsAndConnections = {
systems: SolarSystemRawType[];
connections: SolarSystemConnection[];
};
type UseContextMenuRootHandlers = { type UseContextMenuRootHandlers = {
onAddSystem?: OnMapAddSystemCallback; onAddSystem?: OnMapAddSystemCallback;
onCommand?: OutCommandHandler;
}; };
export const useContextMenuRootHandlers = ({ onAddSystem }: UseContextMenuRootHandlers = {}) => { export const useContextMenuRootHandlers = ({ onAddSystem, onCommand }: UseContextMenuRootHandlers = {}) => {
const rf = useReactFlow(); const rf = useReactFlow();
const contextMenuRef = useRef<ContextMenu | null>(null); const contextMenuRef = useRef<ContextMenu | null>(null);
const [position, setPosition] = useState<XYPosition | null>(null); const [position, setPosition] = useState<XYPosition | null>(null);
const [pasteSystemsAndConnections, setPasteSystemsAndConnections] = useState<PasteSystemsAndConnections>();
const handleRootContext = (e: React.MouseEvent<HTMLDivElement>) => { const handleRootContext = async (e: React.MouseEvent<HTMLDivElement>) => {
setPosition(rf.project({ x: e.clientX, y: e.clientY })); setPosition(rf.project({ x: e.clientX, y: e.clientY }));
e.preventDefault(); e.preventDefault();
ctxManager.next('ctxRoot', contextMenuRef.current); ctxManager.next('ctxRoot', contextMenuRef.current);
contextMenuRef.current?.show(e); contextMenuRef.current?.show(e);
try {
const text = await navigator.clipboard.readText();
const result = decodeUriBase64ToJson(text);
setPasteSystemsAndConnections(result as PasteSystemsAndConnections);
} catch (err) {
setPasteSystemsAndConnections(undefined);
// do nothing
}
}; };
const ref = useRef({ onAddSystem, position }); const ref = useRef({ onAddSystem, position, pasteSystemsAndConnections, onCommand });
ref.current = { onAddSystem, position }; ref.current = { onAddSystem, position, pasteSystemsAndConnections, onCommand };
const onAddSystemCallback = useCallback(() => { const onAddSystemCallback = useCallback(() => {
ref.current.onAddSystem?.({ coordinates: position }); ref.current.onAddSystem?.({ coordinates: position });
}, [position]); }, [position]);
const onPasteSystemsAnsConnections = useCallback(async () => {
const { pasteSystemsAndConnections, onCommand, position } = ref.current;
if (!position || !onCommand || !pasteSystemsAndConnections) {
return;
}
const { systems } = recenterSystemsByBounds(pasteSystemsAndConnections.systems);
await onCommand({
type: OutCommand.manualPasteSystemsAndConnections,
data: {
systems: systems.map(({ position: srcPos, ...rest }) => ({
position: { x: srcPos.x + position.x, y: srcPos.y + position.y },
...rest,
})),
connections: pasteSystemsAndConnections.connections,
},
});
}, []);
return { return {
handleRootContext, handleRootContext,
pasteSystemsAndConnections,
contextMenuRef, contextMenuRef,
onAddSystem: onAddSystemCallback, onAddSystem: onAddSystemCallback,
onPasteSystemsAnsConnections,
}; };
}; };

View File

@@ -3,3 +3,4 @@ export * from './parseSignatures';
export * from './getSystemById'; export * from './getSystemById';
export * from './getEveImageUrl'; export * from './getEveImageUrl';
export * from './toastHelpers'; export * from './toastHelpers';
export * from './recenterSystems';

View File

@@ -0,0 +1,39 @@
import { XYPosition } from 'reactflow';
export type WithPosition<T = unknown> = T & { position: XYPosition };
export const computeBoundsCenter = (items: Array<WithPosition>): XYPosition => {
if (items.length === 0) return { x: 0, y: 0 };
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const { position } of items) {
if (position.x < minX) minX = position.x;
if (position.x > maxX) maxX = position.x;
if (position.y < minY) minY = position.y;
if (position.y > maxY) maxY = position.y;
}
return {
x: minX + (maxX - minX) / 2,
y: minY + (maxY - minY) / 2,
};
};
/** Смещает все точки так, чтобы центр области стал (0,0) */
export const recenterSystemsByBounds = <T extends WithPosition>(items: T[]): { center: XYPosition; systems: T[] } => {
const center = computeBoundsCenter(items);
const systems = items.map(it => ({
...it,
position: {
x: it.position.x - center.x,
y: it.position.y - center.y,
},
}));
return { center, systems };
};

View File

@@ -247,6 +247,7 @@ export enum OutCommand {
deleteSystems = 'delete_systems', deleteSystems = 'delete_systems',
manualAddSystem = 'manual_add_system', manualAddSystem = 'manual_add_system',
manualAddConnection = 'manual_add_connection', manualAddConnection = 'manual_add_connection',
manualPasteSystemsAndConnections = 'manual_paste_systems_and_connections',
manualDeleteConnection = 'manual_delete_connection', manualDeleteConnection = 'manual_delete_connection',
setAutopilotWaypoint = 'set_autopilot_waypoint', setAutopilotWaypoint = 'set_autopilot_waypoint',
addSystem = 'add_system', addSystem = 'add_system',

View File

@@ -3,3 +3,4 @@ export * from './getQueryVariable';
export * from './loadTextFile'; export * from './loadTextFile';
export * from './saveToFile'; export * from './saveToFile';
export * from './omit'; export * from './omit';
export * from './jsonToUriBase64';

View File

@@ -0,0 +1,26 @@
export const encodeJsonToUriBase64 = (value: unknown): string => {
const json = JSON.stringify(value);
const uriEncoded = encodeURIComponent(json);
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
return window.btoa(uriEncoded);
}
// Node.js
// @ts-ignore
return Buffer.from(uriEncoded, 'utf8').toString('base64');
};
export const decodeUriBase64ToJson = <T = unknown>(base64: string): T => {
let uriEncoded: string;
if (typeof window !== 'undefined' && typeof window.atob === 'function') {
uriEncoded = window.atob(base64);
} else {
// Node.js
// @ts-ignore
uriEncoded = Buffer.from(base64, 'base64').toString('utf8');
}
const json = decodeURIComponent(uriEncoded);
return JSON.parse(json) as T;
};