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:
@@ -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 { ObjectNode } from './objectsTypes';
|
||||
import { IObjectTreeView } from './useObjectTreeView';
|
||||
import { IObjectDrag } from './useObjectDrag';
|
||||
import { DropZone } from './DropZone';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
import styles from './ObjectsPage.module.scss';
|
||||
|
||||
@@ -12,7 +10,6 @@ interface Props {
|
||||
node: ObjectNode;
|
||||
canEdit: boolean;
|
||||
view: IObjectTreeView;
|
||||
drag: IObjectDrag;
|
||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||
}
|
||||
|
||||
@@ -20,19 +17,14 @@ interface Props {
|
||||
const ROW_BASE_INDENT_PX = 12;
|
||||
const ROW_LEVEL_INDENT_PX = 22;
|
||||
|
||||
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string {
|
||||
return [
|
||||
styles.nodeRow,
|
||||
hasChildren ? styles.nodeRowParent : '',
|
||||
isSelected ? styles.nodeRowSelected : '',
|
||||
isDragging ? styles.nodeRowDragging : ''
|
||||
]
|
||||
function rowClassName(hasChildren: boolean, isSelected: boolean): string {
|
||||
return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.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 isExpanded = view.expandedIds.has(node.id);
|
||||
|
||||
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropZone targetId={node.id} position="before" drag={drag} />
|
||||
<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 }}
|
||||
draggable={canEdit}
|
||||
onDragStart={(event) => drag.onDragStart(node.id, event)}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
onClick={() => view.clickRow(node)}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<DropZone targetId={node.id} position="inside" drag={drag} />
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<ObjectTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
canEdit={canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
<ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DropZone targetId={node.id} position="after" drag={drag} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.nodeRowDragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.nodeToggle {
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
|
||||
import { useObjectActions } from './useObjectActions';
|
||||
import { useObjectsExport } from './useObjectsExport';
|
||||
import { useObjectTreeView } from './useObjectTreeView';
|
||||
import { useObjectDrag } from './useObjectDrag';
|
||||
import { ObjectsHeader } from './ObjectsHeader';
|
||||
import { ObjectsTree } from './ObjectsTree';
|
||||
import { ObjectModal } from './ObjectModal';
|
||||
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
||||
);
|
||||
|
||||
const data = useObjectsData(repository, projectId);
|
||||
const actions = useObjectActions({
|
||||
repository,
|
||||
projectTitle: data.projectTitle,
|
||||
tree: data.tree,
|
||||
reload: data.reload
|
||||
});
|
||||
const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
|
||||
const exporter = useObjectsExport(repository, projectId);
|
||||
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 [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
|
||||
|
||||
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
||||
tree={data.tree}
|
||||
canEdit={data.canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
|
||||
onAddRoot={openAddRoot}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
|
||||
import { countNodes } from '../_tree/treeRules';
|
||||
import { ObjectNode, ObjectTree } from './objectsTypes';
|
||||
import { IObjectTreeView } from './useObjectTreeView';
|
||||
import { IObjectDrag } from './useObjectDrag';
|
||||
import { ObjectTreeNode } from './ObjectTreeNode';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
import styles from './ObjectsPage.module.scss';
|
||||
@@ -14,13 +13,12 @@ interface Props {
|
||||
tree: ObjectTree;
|
||||
canEdit: boolean;
|
||||
view: IObjectTreeView;
|
||||
drag: IObjectDrag;
|
||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||
onAddRoot: () => void;
|
||||
}
|
||||
|
||||
/** Панель «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}>
|
||||
<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) => (
|
||||
<ObjectTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
canEdit={canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
<ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -174,17 +174,6 @@ describe('запись', () => {
|
||||
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 () => {
|
||||
const verbose = setup([
|
||||
ENTITY_TYPE_ROUTE,
|
||||
|
||||
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
|
||||
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
|
||||
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
|
||||
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>;
|
||||
downloadObjectsExcel(projectId: string): Promise<Blob>;
|
||||
}
|
||||
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
|
||||
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
|
||||
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),
|
||||
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
buildObjectTree,
|
||||
canAddChild,
|
||||
canDrop,
|
||||
getChangedParentId,
|
||||
getDeleteConfirmMessage,
|
||||
getErrorText,
|
||||
@@ -9,8 +8,7 @@ import {
|
||||
getParentDisplay,
|
||||
getParentOptions,
|
||||
getSubmitLabel,
|
||||
isParentSelectable,
|
||||
resolveMove
|
||||
isParentSelectable
|
||||
} from './objectsRules';
|
||||
import { buildObjectMenuItems } from './objectMenu';
|
||||
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('удаление и ошибки', () => {
|
||||
it('подтверждение с числом дочерних', () => {
|
||||
expect(getDeleteConfirmMessage(node(1))).toEqual(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ISelectOption } from '@tnsu/ui-kit';
|
||||
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';
|
||||
|
||||
// Правила страницы «Объекты» (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;
|
||||
}
|
||||
|
||||
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
|
||||
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 {
|
||||
return OBJECTS_TEXTS.deleteConfirm(
|
||||
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
|
||||
|
||||
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
|
||||
node: ObjectNode | undefined;
|
||||
}
|
||||
|
||||
/** Куда бросили узел относительно целевого: до, внутрь, после. */
|
||||
export type DropPosition = 'before' | 'inside' | 'after';
|
||||
|
||||
/**
|
||||
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
|
||||
* Пустой lookup приходит как null — читается через `?.`.
|
||||
|
||||
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
|
||||
deleteFallbackTitle: (id: number): string => `ID ${id}`,
|
||||
serverError: (status: number): string => `Ошибка сервера: ${status}`,
|
||||
saveError: (message: string): string => `Ошибка сохранения: ${message}`,
|
||||
moveError: (message: string): string => `Не удалось переместить: ${message}`,
|
||||
deleteError: (message: string): string => `Не удалось удалить: ${message}`,
|
||||
exportError: (message: string): string => `Не удалось скачать файл: ${message}`
|
||||
} as const;
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { confirmModal } from '@tnsu/ui-kit';
|
||||
import { IObjectsRepository } from './objectsRepository';
|
||||
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules';
|
||||
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes';
|
||||
import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
|
||||
import { IObjectModalTarget, ObjectNode } from './objectsTypes';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
|
||||
interface IActionsDeps {
|
||||
repository: IObjectsRepository;
|
||||
projectTitle: string | undefined;
|
||||
tree: ObjectTree;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export interface IObjectActions {
|
||||
/** Ошибка переноса или удаления (раньше — alert). */
|
||||
/** Ошибка удаления (раньше — alert). */
|
||||
actionError: string | undefined;
|
||||
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
|
||||
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
|
||||
moveObject: (dragId: number, targetId: number, position: DropPosition) => 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 saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
|
||||
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
|
||||
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 isConfirmed = await confirmModal({
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user