refactor: remove drag and drop from the objects tree

Decided during manual testing: the legacy drop-inside zone had zero
height, so drops only worked before/after a node and moves happened by
accident. Sections are re-parented via Edit, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-19 10:07:06 +05:00
parent 8ed1a02967
commit d2852df976
13 changed files with 17 additions and 276 deletions
@@ -1,32 +0,0 @@
import * as React from 'react';
import { DropPosition } from './objectsTypes';
import { IObjectDrag } from './useObjectDrag';
import styles from './ObjectsPage.module.scss';
interface Props {
targetId: number;
position: DropPosition;
drag: IObjectDrag;
}
const POSITION_CLASS: Record<DropPosition, string> = {
before: styles.dropZoneEdge,
inside: styles.dropZoneInside,
after: styles.dropZoneEdge
};
/** Тонкая полоса до / внутри / после узла, куда можно бросить перетаскиваемый объект. */
export const DropZone: React.FC<Props> = ({ targetId, position, drag }) => {
const isActive = drag.activeZone?.targetId === targetId && drag.activeZone.position === position;
const className = [styles.dropZone, POSITION_CLASS[position], isActive ? styles.dropZoneActive : '']
.filter(Boolean)
.join(' ');
return (
<div
className={className}
onDragOver={(event) => drag.onZoneDragOver({ targetId, position }, event)}
onDragLeave={drag.onZoneDragLeave}
/>
);
};
@@ -3,8 +3,6 @@ import { Icon } from '@tnsu/ui-kit';
import { IMenuAnchor } from '../_tree/ContextMenu'; import { IMenuAnchor } from '../_tree/ContextMenu';
import { ObjectNode } from './objectsTypes'; import { ObjectNode } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView'; import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { DropZone } from './DropZone';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss'; import styles from './ObjectsPage.module.scss';
@@ -12,7 +10,6 @@ interface Props {
node: ObjectNode; node: ObjectNode;
canEdit: boolean; canEdit: boolean;
view: IObjectTreeView; view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void; onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
} }
@@ -20,19 +17,14 @@ interface Props {
const ROW_BASE_INDENT_PX = 12; const ROW_BASE_INDENT_PX = 12;
const ROW_LEVEL_INDENT_PX = 22; const ROW_LEVEL_INDENT_PX = 22;
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string { function rowClassName(hasChildren: boolean, isSelected: boolean): string {
return [ return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
styles.nodeRow,
hasChildren ? styles.nodeRowParent : '',
isSelected ? styles.nodeRowSelected : '',
isDragging ? styles.nodeRowDragging : ''
]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
} }
/** Узел дерева объектов с зонами сброса и (если раскрыт) дочерними узлами. */ /** Узел дерева объектов и (если раскрыт) его дочерние узлы. */
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onOpenMenu }) => { export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, onOpenMenu }) => {
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = view.expandedIds.has(node.id); const isExpanded = view.expandedIds.has(node.id);
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
return ( return (
<div> <div>
<DropZone targetId={node.id} position="before" drag={drag} />
<div <div
className={rowClassName(hasChildren, view.selectedId === node.id, drag.draggingId === node.id)} className={rowClassName(hasChildren, view.selectedId === node.id)}
style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }} style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }}
draggable={canEdit}
onDragStart={(event) => drag.onDragStart(node.id, event)}
onDragEnd={drag.onDragEnd}
onClick={() => view.clickRow(node)} onClick={() => view.clickRow(node)}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
</button> </button>
)} )}
</div> </div>
<DropZone targetId={node.id} position="inside" drag={drag} />
{hasChildren && isExpanded && ( {hasChildren && isExpanded && (
<div> <div>
{node.children.map((child) => ( {node.children.map((child) => (
<ObjectTreeNode <ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
key={child.id}
node={child}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))} ))}
</div> </div>
)} )}
<DropZone targetId={node.id} position="after" drag={drag} />
</div> </div>
); );
}; };
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
} }
} }
.nodeRowDragging {
opacity: 0.45;
}
.nodeToggle { .nodeToggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -182,32 +178,6 @@ $menu-button-size: 28px;
} }
} }
// ── Зоны сброса при перетаскивании ──────────────────────────────────────────
// Высоты 1:1 со старым web part: зона «внутрь» нулевой высоты (см. открытые вопросы в чек-листе).
.dropZone {
margin: 0 $sp-2;
border-radius: 1px;
transition: background 0.12s, height 0.12s;
}
.dropZoneEdge {
height: 2px;
&.dropZoneActive {
height: 3px;
background: $gold-400;
}
}
.dropZoneInside {
height: 0;
&.dropZoneActive {
height: 2px;
background: rgba($gold-400, 0.3);
}
}
// ── Модалка ───────────────────────────────────────────────────────────────── // ── Модалка ─────────────────────────────────────────────────────────────────
.modalBody { .modalBody {
display: flex; display: flex;
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
import { useObjectActions } from './useObjectActions'; import { useObjectActions } from './useObjectActions';
import { useObjectsExport } from './useObjectsExport'; import { useObjectsExport } from './useObjectsExport';
import { useObjectTreeView } from './useObjectTreeView'; import { useObjectTreeView } from './useObjectTreeView';
import { useObjectDrag } from './useObjectDrag';
import { ObjectsHeader } from './ObjectsHeader'; import { ObjectsHeader } from './ObjectsHeader';
import { ObjectsTree } from './ObjectsTree'; import { ObjectsTree } from './ObjectsTree';
import { ObjectModal } from './ObjectModal'; import { ObjectModal } from './ObjectModal';
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
); );
const data = useObjectsData(repository, projectId); const data = useObjectsData(repository, projectId);
const actions = useObjectActions({ const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
repository,
projectTitle: data.projectTitle,
tree: data.tree,
reload: data.reload
});
const exporter = useObjectsExport(repository, projectId); const exporter = useObjectsExport(repository, projectId);
const view = useObjectTreeView(data.tree); const view = useObjectTreeView(data.tree);
const drag = useObjectDrag(data.tree, (dragId, targetId, position) => {
actions.moveObject(dragId, targetId, position).catch(() => undefined);
});
const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined); const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined);
const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined); const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
tree={data.tree} tree={data.tree}
canEdit={data.canEdit} canEdit={data.canEdit}
view={view} view={view}
drag={drag}
onOpenMenu={(node, anchor) => setMenu({ node, anchor })} onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
onAddRoot={openAddRoot} onAddRoot={openAddRoot}
/> />
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
import { countNodes } from '../_tree/treeRules'; import { countNodes } from '../_tree/treeRules';
import { ObjectNode, ObjectTree } from './objectsTypes'; import { ObjectNode, ObjectTree } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView'; import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { ObjectTreeNode } from './ObjectTreeNode'; import { ObjectTreeNode } from './ObjectTreeNode';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss'; import styles from './ObjectsPage.module.scss';
@@ -14,13 +13,12 @@ interface Props {
tree: ObjectTree; tree: ObjectTree;
canEdit: boolean; canEdit: boolean;
view: IObjectTreeView; view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void; onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
onAddRoot: () => void; onAddRoot: () => void;
} }
/** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */ /** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */
export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpenMenu, onAddRoot }) => ( export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, onOpenMenu, onAddRoot }) => (
<> <>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span> <span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
@@ -48,16 +46,9 @@ export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpen
/> />
)} )}
<div className={styles.tree} onDrop={drag.onDrop}> <div className={styles.tree}>
{tree.roots.map((node) => ( {tree.roots.map((node) => (
<ObjectTreeNode <ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
key={node.id}
node={node}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))} ))}
</div> </div>
</> </>
@@ -174,17 +174,6 @@ describe('запись', () => {
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 }); expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 });
}); });
it('перенос: меняется только parentId, в корень — null', async () => {
const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
await repository.moveObject(7, 2);
await repository.moveObject(7, undefined);
expect(sharePoint.requests[1].request.headers).toEqual(MERGE_HEADERS);
expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: 2 });
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: null });
});
it('ошибка записи: error.message.value → error.message → statusText', async () => { it('ошибка записи: error.message.value → error.message → statusText', async () => {
const verbose = setup([ const verbose = setup([
ENTITY_TYPE_ROUTE, ENTITY_TYPE_ROUTE,
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>; createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */ /** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>; updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>;
/** Перенос (drag & drop): меняется только родитель; undefined — в корень. */
moveObject(itemId: number, newParentId: number | undefined): Promise<void>;
deleteObject(itemId: number): Promise<void>; deleteObject(itemId: number): Promise<void>;
downloadObjectsExcel(projectId: string): Promise<Blob>; downloadObjectsExcel(projectId: string): Promise<Blob>;
} }
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId; if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body); await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
}, },
moveObject: async (itemId, newParentId) => {
// Перенос в корень сбрасывает lookup явным null.
const body = { ...(await metadata()), [OBJECT_FIELDS.parentWrite]: newParentId ?? null };
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
},
deleteObject: (itemId) => deleteObject(deps, itemId), deleteObject: (itemId) => deleteObject(deps, itemId),
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId) downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
}; };
@@ -1,7 +1,6 @@
import { import {
buildObjectTree, buildObjectTree,
canAddChild, canAddChild,
canDrop,
getChangedParentId, getChangedParentId,
getDeleteConfirmMessage, getDeleteConfirmMessage,
getErrorText, getErrorText,
@@ -9,8 +8,7 @@ import {
getParentDisplay, getParentDisplay,
getParentOptions, getParentOptions,
getSubmitLabel, getSubmitLabel,
isParentSelectable, isParentSelectable
resolveMove
} from './objectsRules'; } from './objectsRules';
import { buildObjectMenuItems } from './objectMenu'; import { buildObjectMenuItems } from './objectMenu';
import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes'; import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
@@ -100,26 +98,6 @@ describe('модалка', () => {
}); });
}); });
describe('перетаскивание', () => {
it('нельзя бросить в себя и в своих потомков', () => {
expect(canDrop(tree, 1, 1)).toBe(false);
expect(canDrop(tree, 1, 4)).toBe(false);
expect(canDrop(tree, 4, 1)).toBe(true);
});
it('«внутрь» — родитель = цель, «до/после» — родитель цели', () => {
expect(resolveMove(tree, 4, 3, 'inside')).toEqual({ parentId: 3 });
expect(resolveMove(tree, 4, 3, 'before')).toEqual({ parentId: 1 });
expect(resolveMove(tree, 2, 5, 'after')).toEqual({ parentId: undefined });
});
it('родитель не меняется или перенос запрещён — ничего не делаем', () => {
expect(resolveMove(tree, 2, 3, 'after')).toBeUndefined();
expect(resolveMove(tree, 1, 5, 'before')).toBeUndefined();
expect(resolveMove(tree, 1, 4, 'inside')).toBeUndefined();
});
});
describe('удаление и ошибки', () => { describe('удаление и ошибки', () => {
it('подтверждение с числом дочерних', () => { it('подтверждение с числом дочерних', () => {
expect(getDeleteConfirmMessage(node(1))).toEqual( expect(getDeleteConfirmMessage(node(1))).toEqual(
@@ -1,6 +1,6 @@
import { ISelectOption } from '@tnsu/ui-kit'; import { ISelectOption } from '@tnsu/ui-kit';
import { buildTree, isDescendant } from '../_tree/treeRules'; import { buildTree, isDescendant } from '../_tree/treeRules';
import { DropPosition, IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes'; import { IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции. // Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
@@ -72,33 +72,6 @@ export function getChangedParentId(node: ObjectNode | undefined, selectValue: st
return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined; return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined;
} }
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
export function canDrop(tree: ObjectTree, dragId: number, targetId: number): boolean {
return dragId !== targetId && !isDescendant(tree.byId, dragId, targetId);
}
export interface IMoveResult {
/** undefined — перенос в корень. */
parentId: number | undefined;
}
/**
* Новый родитель после перетаскивания: «внутрь» — целевой узел, «до/после» — родитель целевого.
* undefined — переносить нечего (родитель не меняется или перенос запрещён).
*/
export function resolveMove(
tree: ObjectTree,
dragId: number,
targetId: number,
position: DropPosition
): IMoveResult | undefined {
const dragNode = tree.byId.get(dragId);
if (!dragNode || !canDrop(tree, dragId, targetId)) return undefined;
const parentId = position === 'inside' ? targetId : tree.byId.get(targetId)?.item.parentId;
return dragNode.item.parentId === parentId ? undefined : { parentId };
}
export function getDeleteConfirmMessage(node: ObjectNode): string { export function getDeleteConfirmMessage(node: ObjectNode): string {
return OBJECTS_TEXTS.deleteConfirm( return OBJECTS_TEXTS.deleteConfirm(
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id), node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
node: ObjectNode | undefined; node: ObjectNode | undefined;
} }
/** Куда бросили узел относительно целевого: до, внутрь, после. */
export type DropPosition = 'before' | 'inside' | 'after';
/** /**
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent). * Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
* Пустой lookup приходит как null — читается через `?.`. * Пустой lookup приходит как null — читается через `?.`.
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
deleteFallbackTitle: (id: number): string => `ID ${id}`, deleteFallbackTitle: (id: number): string => `ID ${id}`,
serverError: (status: number): string => `Ошибка сервера: ${status}`, serverError: (status: number): string => `Ошибка сервера: ${status}`,
saveError: (message: string): string => `Ошибка сохранения: ${message}`, saveError: (message: string): string => `Ошибка сохранения: ${message}`,
moveError: (message: string): string => `Не удалось переместить: ${message}`,
deleteError: (message: string): string => `Не удалось удалить: ${message}`, deleteError: (message: string): string => `Не удалось удалить: ${message}`,
exportError: (message: string): string => `Не удалось скачать файл: ${message}` exportError: (message: string): string => `Не удалось скачать файл: ${message}`
} as const; } as const;
@@ -1,28 +1,26 @@
import * as React from 'react'; import * as React from 'react';
import { confirmModal } from '@tnsu/ui-kit'; import { confirmModal } from '@tnsu/ui-kit';
import { IObjectsRepository } from './objectsRepository'; import { IObjectsRepository } from './objectsRepository';
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules'; import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes'; import { IObjectModalTarget, ObjectNode } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
interface IActionsDeps { interface IActionsDeps {
repository: IObjectsRepository; repository: IObjectsRepository;
projectTitle: string | undefined; projectTitle: string | undefined;
tree: ObjectTree;
reload: () => void; reload: () => void;
} }
export interface IObjectActions { export interface IObjectActions {
/** Ошибка переноса или удаления (раньше — alert). */ /** Ошибка удаления (раньше — alert). */
actionError: string | undefined; actionError: string | undefined;
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */ /** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>; saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
moveObject: (dragId: number, targetId: number, position: DropPosition) => Promise<void>;
deleteObject: (node: ObjectNode) => Promise<void>; deleteObject: (node: ObjectNode) => Promise<void>;
} }
/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */ /** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
export function useObjectActions({ repository, projectTitle, tree, reload }: IActionsDeps): IObjectActions { export function useObjectActions({ repository, projectTitle, reload }: IActionsDeps): IObjectActions {
const [actionError, setActionError] = React.useState<string | undefined>(undefined); const [actionError, setActionError] = React.useState<string | undefined>(undefined);
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => { const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
reload(); reload();
}; };
const moveObject = async (dragId: number, targetId: number, position: DropPosition): Promise<void> => {
const move = resolveMove(tree, dragId, targetId, position);
if (!move) return;
setActionError(undefined);
try {
await repository.moveObject(dragId, move.parentId);
reload();
} catch (error) {
setActionError(OBJECTS_TEXTS.moveError(getErrorText(error)));
}
};
const deleteObject = async (node: ObjectNode): Promise<void> => { const deleteObject = async (node: ObjectNode): Promise<void> => {
const isConfirmed = await confirmModal({ const isConfirmed = await confirmModal({
message: getDeleteConfirmMessage(node), message: getDeleteConfirmMessage(node),
@@ -67,5 +53,5 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
} }
}; };
return { actionError, saveObject, moveObject, deleteObject }; return { actionError, saveObject, deleteObject };
} }
@@ -1,72 +0,0 @@
import * as React from 'react';
import { canDrop } from './objectsRules';
import { DropPosition, ObjectTree } from './objectsTypes';
export interface IDropZone {
targetId: number;
position: DropPosition;
}
export interface IObjectDrag {
draggingId: number | undefined;
/** Подсвеченная зона сброса. */
activeZone: IDropZone | undefined;
onDragStart: (nodeId: number, event: React.DragEvent) => void;
onDragEnd: () => void;
onZoneDragOver: (zone: IDropZone, event: React.DragEvent) => void;
onZoneDragLeave: () => void;
onDrop: (event: React.DragEvent) => void;
}
/**
* Перетаскивание узлов — как в stroy-bot-objects: узел бросается на зону до / внутрь / после
* другого узла. Бросок засчитывается в последнюю «разрешённую» зону, над которой был курсор.
*/
export function useObjectDrag(
tree: ObjectTree,
onMove: (dragId: number, targetId: number, position: DropPosition) => void
): IObjectDrag {
const [draggingId, setDraggingId] = React.useState<number | undefined>(undefined);
const [activeZone, setActiveZone] = React.useState<IDropZone | undefined>(undefined);
// Последняя разрешённая зона: в старом коде dragleave снимал только подсветку, а цель запоминалась.
const dropTargetRef = React.useRef<IDropZone | undefined>(undefined);
const reset = (): void => {
setDraggingId(undefined);
setActiveZone(undefined);
dropTargetRef.current = undefined;
};
const onDragStart = (nodeId: number, event: React.DragEvent): void => {
setDraggingId(nodeId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(nodeId));
};
const onZoneDragOver = (zone: IDropZone, event: React.DragEvent): void => {
if (draggingId === undefined || !canDrop(tree, draggingId, zone.targetId)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setActiveZone(zone);
dropTargetRef.current = zone;
};
const onDrop = (event: React.DragEvent): void => {
event.preventDefault();
const dragId = draggingId;
const target = dropTargetRef.current;
reset();
if (dragId === undefined || !target) return;
onMove(dragId, target.targetId, target.position);
};
return {
draggingId,
activeZone,
onDragStart,
onDragEnd: reset,
onZoneDragOver,
onZoneDragLeave: () => setActiveZone(undefined),
onDrop
};
}