This commit is contained in:
Yuri Kuznetsov
2025-06-19 09:37:40 +03:00
parent bcb8e55c15
commit bffc905c2a
2 changed files with 361 additions and 282 deletions
@@ -0,0 +1,347 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import {Draggable} from '@shopify/draggable';
import {inject} from 'di';
import Language from 'language';
/**
* @internal
*/
export default class ListTreeDraggableHelper {
/**
* @type {Language}
*/
@inject(Language)
language
/**
* @private
* @type {boolean}
*/
blockDraggable = false
/**
* @private
* {Draggable}
*/
draggable
/**
* @param {import('views/record/list-tree').default} view
*/
constructor(view) {
this.view = view;
}
destroy() {
if (this.draggable) {
this.draggable.destroy();
}
}
init() {
if (this.draggable) {
this.draggable.destroy();
}
const draggable = this.draggable = new Draggable(this.view.element, {
distance: 8,
draggable: '.list-group-item > .cell > [data-role="moveHandle"]',
mirror: {
cursorOffsetX: 5,
cursorOffsetY: 5,
appendTo: 'body',
},
});
/** @type {HTMLElement[]} */
let rows;
/** @type {Map<HTMLElement, number>} */
let levelMap;
/** @type {HTMLElement|null} */
let movedHandle = null;
/** @type {HTMLElement|null} */
let movedLink = null;
/** @type {HTMLElement|null} */
let movedFromLi = null;
draggable.on('mirror:created', event => {
const mirror = event.mirror;
const source = event.source;
const originalSource = event.originalSource;
originalSource.style.display = '';
source.style.display = 'none';
mirror.style.display = 'block';
mirror.style.cursor = 'grabbing';
mirror.classList.add('draggable-helper', 'draggable-helper-transparent', 'text-info');
mirror.classList.remove('link');
mirror.style.pointerEvents = 'auto';
mirror.removeAttribute('href');
mirror.style.textDecoration = 'none';
mirror.innerText = mirror.dataset.title;
});
draggable.on('mirror:move', event => {
event.mirror.style.pointerEvents = 'auto';
});
draggable.on('drag:start', event => {
if (this.blockDraggable) {
event.cancel();
return;
}
rows = Array.from(this.view.element.querySelectorAll('.list-group-tree > .list-group-item'));
levelMap = new Map();
rows.forEach(row => {
let depth = 0;
let current = row;
while (current && current !== this.view.element) {
current = current.parentElement;
depth ++;
}
levelMap.set(row, depth);
});
rows.sort((a, b) => levelMap.get(b) - levelMap.get(a));
this.view.movedId = event.source.dataset.id;
movedHandle = event.originalSource;
movedFromLi = movedHandle.parentElement.parentElement;
movedLink = movedHandle.parentElement.querySelector(`:scope > a.link`);
movedLink.classList.add('text-info');
});
let overId = null;
let overParentId = null;
let isAfter = false;
let wasOutOfSelf = false;
draggable.on('drag:move', event => {
isAfter = false;
overId = null;
let rowFound = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const isIn =
rect.left < event.sensorEvent.clientX &&
rect.right > event.sensorEvent.clientX &&
rect.top < event.sensorEvent.clientY &&
rect.bottom >= event.sensorEvent.clientY;
if (!isIn) {
continue;
}
const itemId = row.dataset.id ?? null;
let itemParentId = null;
if (!itemId) {
const parent = row.closest(`.list-group-item[data-id]`);
if (parent instanceof HTMLElement) {
// Over a plus row.
itemParentId = parent.dataset.id;
}
}
const itemIsAfter = event.sensorEvent.clientY - rect.top >= rect.bottom - event.sensorEvent.clientY;
if (itemParentId && itemIsAfter) {
continue;
}
if (itemId === this.view.movedId) {
break;
}
if (movedFromLi.contains(row)) {
break;
}
if (!itemId && !itemParentId) {
continue;
}
if (itemParentId) {
const parent = row.closest(`.list-group-item[data-id]`);
if (parent) {
const childCount = parent
.querySelectorAll(':scope > .children > .list > .list-group > [data-id]')
.length
if (childCount) {
continue;
}
}
}
isAfter = itemIsAfter;
overParentId = itemParentId;
overId = itemId;
rowFound = row;
break;
}
for (const row of rows) {
row.classList.remove('border-top-highlighted');
row.classList.remove('border-bottom-highlighted');
}
if (!rowFound) {
return;
}
if (isAfter) {
rowFound.classList.add('border-bottom-highlighted');
rowFound.classList.remove('border-top-highlighted');
} else {
rowFound.classList.add('border-top-highlighted');
rowFound.classList.remove('border-bottom-highlighted');
}
});
draggable.on('drag:stop', async () => {
const finalize = () => {
if (movedLink) {
movedLink.classList.remove('text-info');
}
rows.forEach(row => {
row.classList.remove('border-bottom-highlighted');
row.classList.remove('border-top-highlighted');
});
rows = undefined;
};
let moveType;
let referenceId = overId;
if (overParentId || overId) {
if (overParentId) {
moveType = 'into';
referenceId = overParentId;
} else if (isAfter) {
moveType = 'after';
} else {
moveType = 'before';
}
}
if (moveType) {
this.blockDraggable = true;
const movedId = this.view.movedId;
const affectedId = referenceId;
Espo.Ui.notifyWait();
Espo.Ajax
.postRequest(`${this.view.entityType}/action/move`, {
id: this.view.movedId,
referenceId: referenceId,
type: moveType,
})
.then(async () => {
/**
* @param {ListTreeRecordView} view
* @param {string} movedId
* @return {Promise}
*/
const update = async (view, movedId) => {
if (view.collection.has(movedId)) {
await view.collection.fetch();
return;
}
for (const subView of view.getItemViews()) {
if (!subView.getChildrenView()) {
continue;
}
await update(subView.getChildrenView(), movedId);
}
};
const promises = [];
if (movedId) {
promises.push(update(this.view, movedId));
}
if (affectedId) {
promises.push(update(this.view, affectedId));
}
await Promise.all(promises);
Espo.Ui.success(this.language.translate('Done'));
})
.finally(() => {
this.blockDraggable = false;
finalize();
});
}
if (!moveType) {
finalize();
}
this.view.movedId = null;
movedHandle = null;
movedFromLi = null;
levelMap = undefined;
overParentId = null;
overId = null;
isAfter = false;
wasOutOfSelf = false;
});
}
}
+14 -282
View File
@@ -30,7 +30,7 @@
import ListRecordView from 'views/record/list';
import RecordModal from 'helpers/record-modal';
import {Draggable} from '@shopify/draggable';
import ListTreeDraggableHelper from 'helpers/list/misc/list-tree-draggable';
class ListTreeRecordView extends ListRecordView {
@@ -72,12 +72,6 @@ class ListTreeRecordView extends ListRecordView {
*/
movedId = null
/**
* @private
* @type {boolean}
*/
blockDraggable = false
/**
* @private
* @type {boolean}
@@ -86,9 +80,9 @@ class ListTreeRecordView extends ListRecordView {
/**
* @private
* {Draggable}
* @type {ListTreeDraggableHelper}
*/
draggable
draggableHelper
// noinspection JSCheckFunctionSignatures
data() {
@@ -182,6 +176,14 @@ class ListTreeRecordView extends ListRecordView {
}
}
onRemove() {
super.onRemove();
if (this.draggableHelper) {
this.draggableHelper.destroy();
}
}
afterRender() {
super.afterRender();
@@ -357,281 +359,11 @@ class ListTreeRecordView extends ListRecordView {
* @private
*/
initDraggableRoot() {
if (this.draggable) {
this.draggable.destroy();
if (!this.draggableHelper) {
this.draggableHelper = new ListTreeDraggableHelper(this);
}
const draggable = this.draggable = new Draggable(this.element, {
distance: 8,
draggable: '.list-group-item > .cell > [data-role="moveHandle"]',
mirror: {
cursorOffsetX: 5,
cursorOffsetY: 5,
appendTo: 'body',
},
});
/** @type {HTMLElement[]} */
let rows;
/** @type {Map<HTMLElement, number>} */
let levelMap;
/** @type {HTMLElement|null} */
let movedHandle = null;
/** @type {HTMLElement|null} */
let movedLink = null;
/** @type {HTMLElement|null} */
let movedFromLi = null;
draggable.on('mirror:created', event => {
const mirror = event.mirror;
const source = event.source;
const originalSource = event.originalSource;
originalSource.style.display = '';
source.style.display = 'none';
mirror.style.display = 'block';
mirror.style.cursor = 'grabbing';
mirror.classList.add('draggable-helper', 'draggable-helper-transparent', 'text-info');
mirror.classList.remove('link');
mirror.style.pointerEvents = 'auto';
mirror.removeAttribute('href');
mirror.style.textDecoration = 'none';
mirror.innerText = mirror.dataset.title;
});
draggable.on('mirror:move', event => {
event.mirror.style.pointerEvents = 'auto';
});
draggable.on('drag:start', event => {
if (this.blockDraggable) {
event.cancel();
return;
}
rows = Array.from(this.element.querySelectorAll('.list-group-tree > .list-group-item'));
levelMap = new Map();
rows.forEach(row => {
let depth = 0;
let current = row;
while (current && current !== this.element) {
current = current.parentElement;
depth ++;
}
levelMap.set(row, depth);
});
rows.sort((a, b) => levelMap.get(b) - levelMap.get(a));
this.movedId = event.source.dataset.id;
movedHandle = event.originalSource;
movedFromLi = movedHandle.parentElement.parentElement;
movedLink = movedHandle.parentElement.querySelector(`:scope > a.link`);
movedLink.classList.add('text-info');
});
let overId = null;
let overParentId = null;
let isAfter = false;
let wasOutOfSelf = false;
draggable.on('drag:move', event => {
isAfter = false;
overId = null;
let rowFound = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const isIn =
rect.left < event.sensorEvent.clientX &&
rect.right > event.sensorEvent.clientX &&
rect.top < event.sensorEvent.clientY &&
rect.bottom >= event.sensorEvent.clientY;
if (!isIn) {
continue;
}
const itemId = row.dataset.id ?? null;
let itemParentId = null;
if (!itemId) {
const parent = row.closest(`.list-group-item[data-id]`);
if (parent instanceof HTMLElement) {
// Over a plus row.
itemParentId = parent.dataset.id;
}
}
const itemIsAfter = event.sensorEvent.clientY - rect.top >= rect.bottom - event.sensorEvent.clientY;
if (itemParentId && itemIsAfter) {
continue;
}
if (itemId === this.movedId) {
break;
}
if (movedFromLi.contains(row)) {
break;
}
if (!itemId && !itemParentId) {
continue;
}
if (itemParentId) {
const parent = row.closest(`.list-group-item[data-id]`);
if (parent) {
const childCount = parent
.querySelectorAll(':scope > .children > .list > .list-group > [data-id]')
.length
if (childCount) {
continue;
}
}
}
isAfter = itemIsAfter;
overParentId = itemParentId;
overId = itemId;
rowFound = row;
break;
}
for (const row of rows) {
row.classList.remove('border-top-highlighted');
row.classList.remove('border-bottom-highlighted');
}
if (!rowFound) {
return;
}
if (isAfter) {
rowFound.classList.add('border-bottom-highlighted');
rowFound.classList.remove('border-top-highlighted');
} else {
rowFound.classList.add('border-top-highlighted');
rowFound.classList.remove('border-bottom-highlighted');
}
});
draggable.on('drag:stop', async () => {
const finalize = () => {
if (movedLink) {
movedLink.classList.remove('text-info');
}
rows.forEach(row => {
row.classList.remove('border-bottom-highlighted');
row.classList.remove('border-top-highlighted');
});
};
rows = undefined;
let moveType;
let referenceId = overId;
if (overParentId || overId) {
if (overParentId) {
moveType = 'into';
referenceId = overParentId;
} else if (isAfter) {
moveType = 'after';
} else {
moveType = 'before';
}
}
if (moveType) {
this.blockDraggable = true;
const movedId = this.movedId;
const affectedId = referenceId;
Espo.Ui.notifyWait();
Espo.Ajax
.postRequest(`${this.entityType}/action/move`, {
id: this.movedId,
referenceId: referenceId,
type: moveType,
})
.then(async () => {
/**
* @param {ListTreeRecordView} view
* @param {string} movedId
* @return {Promise}
*/
const update = async (view, movedId) => {
if (view.collection.has(movedId)) {
await view.collection.fetch();
return;
}
for (const subView of view.getItemViews()) {
if (!subView.getChildrenView()) {
continue;
}
await update(subView.getChildrenView(), movedId);
}
};
const promises = [];
if (movedId) {
promises.push(update(this, movedId));
}
if (affectedId) {
promises.push(update(this, affectedId));
}
await Promise.all(promises);
Espo.Ui.success(this.translate('Done'));
})
.finally(() => {
this.blockDraggable = false;
finalize();
});
}
if (!moveType) {
finalize();
}
this.movedId = null;
movedHandle = null;
movedFromLi = null;
levelMap = undefined;
overParentId = null;
overId = null;
isAfter = false;
wasOutOfSelf = false;
});
this.draggableHelper.init();
}
}