Commit 66cd4ab5 authored by Benni Mack's avatar Benni Mack Committed by Richard Haeser
Browse files

[TASK] Rework Drag+Drop for SVG trees

This change splits up Drag+Drop handling in general
for SVG-based trees into PageTree specific logic, and
common usages. This makes Drag+Drop functionality
usable in more places than just the PageTree

In addition, all usages of jQuery are removed and
proper types were added instead of using "any".

Resolves: #93478
Releases: master
Change-Id: Ibb17cdd718424760676899b7746cb0828e06fc7d
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67682

Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
parent 26960825
......@@ -48,7 +48,7 @@ export class SelectTree extends SvgTree
* @param {Selection} nodes
*/
public updateNodes(nodes: TreeNodeSelection): void {
if (this.settings.showCheckboxes) {
if (!this.settings.showCheckboxes) {
return;
}
nodes
......
......@@ -15,13 +15,12 @@ import * as d3selection from 'd3-selection';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {SvgTree, SvgTreeSettings, TreeNodeSelection} from '../SvgTree';
import {TreeNode} from '../Tree/TreeNode';
import {PageTreeDragDrop} from './PageTreeDragDrop';
import {PageTreeDragDrop, PageTreeNodeDragHandler} from './PageTreeDragDrop';
import Icons = require('../Icons');
import Notification = require('../Notification');
import ContextMenu = require('../ContextMenu');
import Persistent from '../Storage/Persistent';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {PageTreeNodeDragHandler} from './PageTreeDragHandler';
import {TreeInterface} from '../Viewport/TreeInterface';
interface PageTreeSettings extends SvgTreeSettings {
......@@ -30,7 +29,7 @@ interface PageTreeSettings extends SvgTreeSettings {
export class PageTree extends SvgTree implements TreeInterface
{
protected settings: PageTreeSettings;
public settings: PageTreeSettings;
private originalNodes: string = '';
private searchQuery: string = '';
private dragDrop: PageTreeDragDrop;
......@@ -399,102 +398,6 @@ export class PageTree extends SvgTree implements TreeInterface
return this.dragDrop.connectDragHandler(new PageTreeNodeDragHandler(this, this.dragDrop))
}
/**
* Add new node to the tree (used in drag+drop)
*
* @type {Object} options
* @private
*/
public addNewNode(options: any) {
const target = options.target;
let index = this.nodes.indexOf(target);
let newNode = {} as any;
newNode.command = 'new';
newNode.type = options.type;
newNode.identifier = -1;
newNode.target = target;
newNode.parents = target.parents;
newNode.parentsStateIdentifier = target.parentsStateIdentifier;
newNode.depth = target.depth;
newNode.position = options.position;
newNode.name = (typeof options.title !== 'undefined') ? options.title : TYPO3.lang['tree.defaultPageTitle'];
newNode.y = newNode.y || newNode.target.y;
newNode.x = newNode.x || newNode.target.x;
this.nodeIsEdit = true;
if (options.position === 'in') {
newNode.depth++;
newNode.parents.unshift(index);
newNode.parentsStateIdentifier.unshift(this.nodes[index].stateIdentifier);
this.nodes[index].hasChildren = true;
this.showChildren(this.nodes[index]);
}
if (options.position === 'in' || options.position === 'after') {
index++;
}
if (options.icon) {
newNode.icon = options.icon;
}
if (newNode.position === 'before') {
let positionAndTarget = this.dragDrop.setNodePositionAndTarget(index);
newNode.position = positionAndTarget[0];
newNode.target = positionAndTarget[1];
}
this.nodes.splice(index, 0, newNode);
this.setParametersNode();
this.prepareDataForVisibleNodes();
this.update();
this.removeEditedText();
d3selection.select(this.svg.node().parentNode as HTMLElement)
.append('input')
.attr('class', 'node-edit')
.style('top', newNode.y + this.settings.marginTop + 'px')
.style('left', newNode.x + this.textPosition + 5 + 'px')
.style('width', this.settings.width - (newNode.x + this.textPosition + 20) + 'px')
.style('height', this.settings.nodeHeight + 'px')
.attr('text', 'text')
.attr('value', newNode.name)
.on('keydown', (evt: KeyboardEvent) => {
const target = evt.target as HTMLInputElement;
const code = evt.keyCode;
if (code === 13 || code === 9) { // enter || tab
this.nodeIsEdit = false;
const newName = target.value.trim();
if (newName.length) {
newNode.name = newName;
this.removeEditedText();
this.sendChangeCommand(newNode);
} else {
this.removeNode(newNode);
}
} else if (code === 27) { // esc
this.nodeIsEdit = false;
this.removeNode(newNode);
}
})
.on('blur', (evt: FocusEvent) => {
if (this.nodeIsEdit && (this.nodes.indexOf(newNode) > -1)) {
const target = evt.target as HTMLInputElement;
const newName = target.value.trim();
if (newName.length) {
newNode.name = newName;
this.removeEditedText();
this.sendChangeCommand(newNode);
} else {
this.removeNode(newNode);
}
}
})
.node()
.select();
}
/**
* Event handler for double click on a node's label
* Changed text position if there is 'stop page tree' option
......@@ -728,6 +631,5 @@ export class PageTree extends SvgTree implements TreeInterface
Notification.error(title, desc);
this.loadData();
};
}
}
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
/** @ts-ignore */
import $ from 'jquery';
import {html, TemplateResult} from 'lit-element';
import {renderNodes} from 'TYPO3/CMS/Core/lit-helper';
import {D3DragEvent} from 'd3-drag';
import Modal = require('../Modal');
import Severity = require('../Severity');
/**
* Currently this library has a lot of cross-cutting functionality
* because it touches "PageTreeDragDrop" and "PageTree" directly, also setting
* options for tree options, which the tree evaluates again.
*/
export interface DragDropHandler {
startDrag: boolean;
startPageX: number;
startPageY: number;
dragStart(event: D3DragEvent<any, any, any>): boolean;
dragDragged(event: D3DragEvent<any, any, any>): boolean;
dragEnd(event: D3DragEvent<any, any, any>): boolean;
}
/**
* Returns template for dragged node
*/
class DraggableTemplate {
public static get(icon: string, name: string): TemplateResult {
return html`<div class="node-dd node-dd--nodrop">
<div class="node-dd__ctrl-icon"></div>
<div class="node-dd__text">
<span class="node-dd__icon">
<svg aria-hidden="true" style="width: 16px; height: 16px">
<use xlink:ref="${icon}"></use>
</svg>
</span>
<span class="node-dd__name">${name}</span>
</div>
</div>`;
}
}
/**
* Main Handler for the toolbar when creating new items
*/
export class ToolbarDragHandler implements DragDropHandler {
public startDrag: boolean = false;
public startPageX: number = 0;
public startPageY: number = 0;
private readonly id: string = '';
private readonly name: string = '';
private readonly tooltip: string = '';
private readonly icon: string = '';
private isDragged: boolean = false;
private dragDrop: any;
private tree: any;
constructor(item: any, tree: any, dragDrop: any) {
this.id = item.nodeType;
this.name = item.title;
this.tooltip = item.tooltip;
this.icon = item.icon;
this.tree = tree;
this.dragDrop = dragDrop;
}
public dragStart(event: D3DragEvent<any, any, any>): boolean {
this.isDragged = false;
this.startDrag = false;
this.startPageX = event.sourceEvent.pageX;
this.startPageY = event.sourceEvent.pageY;
return true;
};
public dragDragged(event: D3DragEvent<any, any, any>): boolean {
if (this.dragDrop.isDragNodeDistanceMore(event, this)) {
this.startDrag = true;
} else {
return false;
}
// Add the draggable element
if (this.isDragged === false) {
this.isDragged = true;
let $svg = $(this.tree.svg.node());
$svg.after($(renderNodes(DraggableTemplate.get('#icon-' + this.icon, this.name))));
$svg.find('.nodes-wrapper').addClass('nodes-wrapper--dragging');
}
let left = 18;
let top = 15;
if (event.sourceEvent && event.sourceEvent.pageX) {
left += event.sourceEvent.pageX;
}
if (event.sourceEvent && event.sourceEvent.pageY) {
top += event.sourceEvent.pageY;
}
this.dragDrop.openNodeTimeout();
$(document).find('.node-dd').css({
left: left,
top: top,
display: 'block'
});
this.dragDrop.changeNodeClasses(event);
return true;
};
public dragEnd(event: D3DragEvent<any, any, any>): boolean {
if (!this.startDrag) {
return false;
}
let $svg = $(this.tree.svg.node());
let $nodesBg = $svg.find('.nodes-bg');
let $nodesWrap = $svg.find('.nodes-wrapper');
this.isDragged = false;
this.dragDrop.addNodeDdClass($nodesWrap, null, '', true);
$nodesBg.find('.node-bg.node-bg--dragging').removeClass('node-bg--dragging');
$svg.siblings('.node-dd').remove();
this.tree.nodesBgContainer.selectAll('.node-bg__border').style('display', 'none');
if (this.tree.settings.isDragAnDrop !== true || !this.tree.hoveredNode || !this.tree.isOverSvg) {
return false;
}
if (this.tree.settings.canNodeDrag) {
this.tree.addNewNode({
type: this.id,
name: this.name,
tooltip: this.tooltip,
icon: this.icon,
position: this.tree.settings.nodeDragPosition,
target: this.tree.hoveredNode
});
}
return true;
};
}
/**
* Drag and drop for nodes (copy/move) including the deleting / drop functionality.
*/
export class PageTreeNodeDragHandler implements DragDropHandler {
public startDrag: boolean = false;
public startPageX: number = 0;
public startPageY: number = 0;
/**
* SVG <g> container for deleting drop zone
*
* @type {Selection}
*/
private dropZoneDelete: any;
private isDragged: boolean = false;
private tree: any;
private dragDrop: any;
private nodeIsOverDelete: boolean = false;
constructor(tree: any, dragDrop: any) {
this.tree = tree;
this.dragDrop = dragDrop;
}
public dragStart(event: D3DragEvent<any, any, any>): boolean {
let node = event.subject;
if (this.tree.settings.isDragAnDrop !== true || node.depth === 0) {
return false;
}
this.dropZoneDelete = null;
if (node.allowDelete) {
this.dropZoneDelete = this.tree.nodesContainer
.select('.node[data-state-id="' + node.stateIdentifier + '"]')
.append('g')
.attr('class', 'nodes-drop-zone')
.attr('height', this.tree.settings.nodeHeight);
this.nodeIsOverDelete = false;
this.dropZoneDelete.append('rect')
.attr('height', this.tree.settings.nodeHeight)
.attr('width', '50px')
.attr('x', 0)
.attr('y', 0)
.on('mouseover', () => {
this.nodeIsOverDelete = true;
})
.on('mouseout', () => {
this.nodeIsOverDelete = false;
});
this.dropZoneDelete.append('text')
.text(TYPO3.lang.deleteItem)
.attr('dx', 5)
.attr('dy', 15);
this.dropZoneDelete.node().dataset.open = 'false';
this.dropZoneDelete.node().style.transform = this.getDropZoneCloseTransform(node);
}
this.startPageX = event.sourceEvent.pageX;
this.startPageY = event.sourceEvent.pageY;
this.startDrag = false;
return true;
};
public dragDragged(event: D3DragEvent<any, any, any>): boolean {
let node = event.subject;
if (this.dragDrop.isDragNodeDistanceMore(event, this)) {
this.startDrag = true;
} else {
return false;
}
if (this.tree.settings.isDragAnDrop !== true || node.depth === 0) {
return false;
}
this.tree.settings.nodeDrag = node;
let $svg = $(event.sourceEvent.target).closest('svg');
let $nodesBg = $svg.find('.nodes-bg');
let $nodesWrap = $svg.find('.nodes-wrapper');
let $nodeBg = $nodesBg.find('.node-bg[data-state-id=' + node.stateIdentifier + ']');
let $nodeDd = $svg.siblings('.node-dd');
// Create the draggable
if ($nodeBg.length && !this.isDragged) {
this.tree.settings.dragging = true;
this.isDragged = true;
$svg.after($(renderNodes(DraggableTemplate.get(this.tree.getIconId(node), node.name))));
$nodeBg.addClass('node-bg--dragging');
$svg.find('.nodes-wrapper').addClass('nodes-wrapper--dragging');
}
let left = 18;
let top = 15;
if (event.sourceEvent && event.sourceEvent.pageX) {
left += event.sourceEvent.pageX;
}
if (event.sourceEvent && event.sourceEvent.pageY) {
top += event.sourceEvent.pageY;
}
this.tree.settings.nodeDragPosition = false;
this.dragDrop.openNodeTimeout();
$(document).find('.node-dd').css({
left: left,
top: top,
display: 'block'
});
if (node.isOver
|| (this.tree.hoveredNode && this.tree.hoveredNode.parentsStateIdentifier.indexOf(node.stateIdentifier) !== -1)
|| !this.tree.isOverSvg) {
this.dragDrop.addNodeDdClass($nodesWrap, $nodeDd, 'nodrop');
if (!this.tree.isOverSvg) {
this.tree.nodesBgContainer
.selectAll('.node-bg__border')
.style('display', 'none');
}
if (this.dropZoneDelete && this.dropZoneDelete.node().dataset.open !== 'true' && this.tree.isOverSvg) {
this.animateDropZone('show', this.dropZoneDelete.node(), node);
}
} else if (!this.tree.hoveredNode) {
this.dragDrop.addNodeDdClass($nodesWrap, $nodeDd, 'nodrop');
this.tree.nodesBgContainer
.selectAll('.node-bg__border')
.style('display', 'none');
} else {
if (this.dropZoneDelete && this.dropZoneDelete.node().dataset.open !== 'false') {
this.animateDropZone('hide', this.dropZoneDelete.node(), node);
}
this.dragDrop.changeNodeClasses(event);
}
return true;
}
public dragEnd(event: D3DragEvent<any, any, any>): boolean {
let node = event.subject;
if (this.dropZoneDelete && this.dropZoneDelete.node().dataset.open === 'true') {
let dropZone = this.dropZoneDelete;
this.animateDropZone('hide', this.dropZoneDelete.node(), node, () => {
dropZone.remove();
this.dropZoneDelete = null;
});
} else {
this.dropZoneDelete = null;
}
if (!this.startDrag || this.tree.settings.isDragAnDrop !== true || node.depth === 0) {
return false;
}
let $svg = $(event.sourceEvent.target).closest('svg');
let $nodesBg = $svg.find('.nodes-bg');
let droppedNode = this.tree.hoveredNode;
this.isDragged = false;
this.dragDrop.addNodeDdClass($svg.find('.nodes-wrapper'), null, '', true);
$nodesBg.find('.node-bg.node-bg--dragging').removeClass('node-bg--dragging');
$svg.siblings('.node-dd').remove();
this.tree.nodesBgContainer.selectAll('.node-bg__border').style('display', 'none');
if (
!(node.isOver
|| (droppedNode && droppedNode.parentsStateIdentifier.indexOf(node.stateIdentifier) !== -1)
|| !this.tree.settings.canNodeDrag
|| !this.tree.isOverSvg
)
) {
let options = this.dragDrop.changeNodePosition(droppedNode, '');
let modalText = options.position === 'in' ? TYPO3.lang['mess.move_into'] : TYPO3.lang['mess.move_after'];
modalText = modalText.replace('%s', options.node.name).replace('%s', options.target.name);
Modal.confirm(
TYPO3.lang.move_page,
modalText,
Severity.warning, [
{
text: $(this).data('button-close-text') || TYPO3.lang['labels.cancel'] || 'Cancel',
active: true,
btnClass: 'btn-default',
name: 'cancel'
},
{
text: $(this).data('button-ok-text') || TYPO3.lang['cm.copy'] || 'Copy',
btnClass: 'btn-warning',
name: 'copy'
},
{
text: $(this).data('button-ok-text') || TYPO3.lang['labels.move'] || 'Move',
btnClass: 'btn-warning',
name: 'move'
}
])
.on('button.clicked', (e: JQueryEventObject) => {
const target = e.target as HTMLInputElement;
if (target.name === 'move') {
options.command = 'move';
this.tree.sendChangeCommand(options);
} else if (target.name === 'copy') {
options.command = 'copy';
this.tree.sendChangeCommand(options);
}
Modal.dismiss();
});
} else if (this.nodeIsOverDelete) {
let options = this.dragDrop.changeNodePosition(droppedNode, 'delete');
if (this.tree.settings.displayDeleteConfirmation) {
let $modal = Modal.confirm(
TYPO3.lang.deleteItem,
TYPO3.lang['mess.delete'].replace('%s', options.node.name),
Severity.warning, [
{
text: $(this).data('button-close-text') || TYPO3.lang['labels.cancel'] || 'Cancel',
active: true,
btnClass: 'btn-default',
name: 'cancel'
},
{
text: $(this).data('button-ok-text') || TYPO3.lang['cm.delete'] || 'Delete',
btnClass: 'btn-warning',
name: 'delete'
}
]);
$modal.on('button.clicked', (e: JQueryEventObject) => {
const target = e.target as HTMLInputElement;
if (target.name === 'delete') {
this.tree.sendChangeCommand(options);
}
Modal.dismiss();
});