Commit 85c7264b authored by Benni Mack's avatar Benni Mack Committed by Georg Ringer
Browse files

[TASK] Clean up Drag&Drop Handling in PageTree

The PageTree components for the navigation area consists of:
* PageTreeElement (container for rendering the navigation area w. toolbar + dragdrop + tree)
* PageTreeToolbar (uses DnD for creating new items)
* PageTree (JS, subclass of SvgTree)
* PageTreeDragDrop

This patch aims to rework the "PageTreeDragDrop" javascript file
by moving separate logic into separate classes (as much as possible)
and into a more stable API, being a first part.

PageTreeDragDrop is a mixed code class currently,
and is now split up in various separate classes:
* DragDropHandler (interface)
* ToolbarDragHandler -> used as the Toolbar Draggable Handler for new pages
* PageTreeNodeDragHandler => used for moving/copying and deleting existing pages/nodes
* PageTreeDragDrop now acts as a simple wrapper for d3-drag but still has some shared state

For Future Reference:
It is still up to decide on how to further decouple the code.

Idea 1: Putting the d3-drag initialization into the right
place and avoiding cross-dependencies. In general "initializeDragForNode"
would also be handled via events, and all options / settings would be
moved into the PageTreeDragDrop class.

Ideally the PageTreeDragDrop contains the status of the
current drag+drop action (position, node etc), so the PageTree
would not know of anything itself.

Idea 2: Another possibility is to use the current "PageTreeDragDrop"
as a generic "TreeDragDropManager" because it does not
contain any Page-Tree specific code anymore, but could be carried
on later-on.

The current change is already a good step as it moves all code
to TypeScript and minimizes injection via constructors (except
for the Tree itself).

Resolves: #93446
Releases: master
Change-Id: Ibd7a067c1e6a821b138a8cc2971ec8133392b600
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67650


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
parent 6887ea3b
/*
* 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 {DragDropHandler} from './PageTreeDragHandler';
import * as d3drag from 'd3-drag';
import * as d3selection from 'd3-selection';
/**
* Mixture class. Contains the information about drag+drop of one tree instance.
*
* @exports TYPO3/CMS/Backend/PageTree/PageTreeDragDrop
*/
export class PageTreeDragDrop {
private timeout: any = {};
private tree: any;
public static setDragStart(): void {
$('body iframe').css({'pointer-events': 'none'});
}
public static setDragEnd(): void {
$('body iframe').css({'pointer-events': ''});
}
constructor(svgTree: any) {
this.tree = svgTree;
}
/**
* Creates a new drag instance and initializes the clickDistance setting to
* prevent clicks from being wrongly detected as drag attempts.
*/
public connectDragHandler(dragHandler: DragDropHandler) {
return d3drag
.drag()
.clickDistance(5)
.on('start', function(evt: d3drag.D3DragEvent<any, any, any>) { dragHandler.dragStart(evt) && PageTreeDragDrop.setDragStart(); })
.on('drag', function(evt: d3drag.D3DragEvent<any, any, any>) { dragHandler.dragDragged(evt); })
.on('end', function(evt: d3drag.D3DragEvent<any, any, any>) { PageTreeDragDrop.setDragEnd(); dragHandler.dragEnd(evt); })
}
/**
* Open node with children while holding the node/element over this node for 1 second
*/
public openNodeTimeout(): void {
if (this.tree.settings.nodeOver.node && this.tree.settings.nodeOver.node.hasChildren && !this.tree.settings.nodeOver.node.expanded) {
if (this.timeout.node != this.tree.settings.nodeOver.node) {
this.timeout.node = this.tree.settings.nodeOver;
clearTimeout(this.timeout.time);
this.timeout.time = setTimeout(() => {
if (this.tree.settings.nodeOver.node) {
this.tree.showChildren(this.tree.settings.nodeOver.node);
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
}, 1000);
}
} else {
clearTimeout(this.timeout.time);
}
}
public changeNodeClasses(event: any): void {
const elementNodeBg = this.tree.svg.select('.node-over');
const $svg = $(this.tree.svg.node());
const $nodesWrap = $svg.find('.nodes-wrapper');
const $nodeDd = $svg.siblings('.node-dd');
let nodeBgBorder = this.tree.nodesBgContainer.selectAll('.node-bg__border');
if (elementNodeBg.size() && this.tree.isOverSvg) {
// line between nodes
if (nodeBgBorder.empty()) {
nodeBgBorder = this.tree.nodesBgContainer
.append('rect')
.attr('class', 'node-bg__border')
.attr('height', '1px')
.attr('width', '100%');
}
const coordinates = d3selection.pointer(event, elementNodeBg.node());
let y = coordinates[1];
if (y < 3) {
nodeBgBorder
.attr('transform', 'translate(-8, ' + (this.tree.settings.nodeOver.node.y - 10) + ')')
.style('display', 'block');
if (this.tree.settings.nodeOver.node.depth === 0) {
this.addNodeDdClass($nodesWrap, $nodeDd, 'nodrop');
} else if (this.tree.settings.nodeOver.node.firstChild) {
this.addNodeDdClass($nodesWrap, $nodeDd, 'ok-above');
} else {
this.addNodeDdClass($nodesWrap, $nodeDd, 'ok-between');
}
this.tree.settings.nodeDragPosition = 'before';
} else if (y > 17) {
nodeBgBorder.style('display', 'none');
if (this.tree.settings.nodeOver.node.expanded && this.tree.settings.nodeOver.node.hasChildren) {
this.addNodeDdClass($nodesWrap, $nodeDd, 'ok-append');
this.tree.settings.nodeDragPosition = 'in';
} else {
nodeBgBorder
.attr('transform', 'translate(-8, ' + (this.tree.settings.nodeOver.node.y + 10) + ')')
.style('display', 'block');
if (this.tree.settings.nodeOver.node.lastChild) {
this.addNodeDdClass($nodesWrap, $nodeDd, 'ok-below');
} else {
this.addNodeDdClass($nodesWrap, $nodeDd, 'ok-between');
}
this.tree.settings.nodeDragPosition = 'after';
}
} else {
nodeBgBorder.style('display', 'none');
this.addNodeDdClass($nodesWrap, $nodeDd, 'ok-append');
this.tree.settings.nodeDragPosition = 'in';
}
} else {
this.tree.nodesBgContainer
.selectAll('.node-bg__border')
.style('display', 'none');
this.addNodeDdClass($nodesWrap, $nodeDd, 'nodrop');
}
}
public addNodeDdClass($nodesWrap: any, $nodeDd: any, className: string = '', remove: boolean = false): void {
const clearClass = ' #prefix#--nodrop #prefix#--ok-append #prefix#--ok-below #prefix#--ok-between #prefix#--ok-above';
let rmClass = '';
let addClass = '';
let options = {
rmClass: ''
} as any;
if (remove === true) {
options = {
rmClass: 'dragging',
setCanNodeDrag: false
};
}
if ($nodeDd) {
rmClass = (options.rmClass ? ' node-dd--' + options.rmClass : '');
addClass = (className ? 'node-dd--' + className : '');
$nodeDd
.removeClass(clearClass.replace(new RegExp('#prefix#', 'g'), 'node-dd') + rmClass)
.addClass(addClass);
}
if ($nodesWrap) {
rmClass = (options.rmClass ? ' nodes-wrapper--' + options.rmClass : '');
addClass = (className ? 'nodes-wrapper--' + className : '');
$nodesWrap
.removeClass(clearClass.replace(new RegExp('#prefix#', 'g'), 'nodes-wrapper') + rmClass)
.addClass(addClass);
}
if ((typeof options.setCanNodeDrag === 'undefined') || options.setCanNodeDrag) {
this.tree.settings.canNodeDrag = !(className === 'nodrop');
}
}
/**
* Check if node is dragged at least @distance
*
* @param {Event} event
* @param {DragDropHandler} dragHandler
* @returns {boolean}
*/
public isDragNodeDistanceMore(event: d3drag.D3DragEvent<any, any, any>, dragHandler: DragDropHandler): boolean {
const distance = 10;
return (dragHandler.startDrag ||
(((dragHandler.startPageX - distance) > event.sourceEvent.pageX) ||
((dragHandler.startPageX + distance) < event.sourceEvent.pageX) ||
((dragHandler.startPageY - distance) > event.sourceEvent.pageY) ||
((dragHandler.startPageY + distance) < event.sourceEvent.pageY)));
}
public changeNodePosition(droppedNode: any, command: string = ''): any {
const nodes = this.tree.nodes;
const uid = this.tree.settings.nodeDrag.identifier;
const index = nodes.indexOf(droppedNode);
let position = this.tree.settings.nodeDragPosition;
let target = droppedNode || this.tree.settings.nodeDrag;
if (uid === target.identifier && command !== 'delete') {
return;
}
if (position === 'before') {
const positionAndTarget = this.setNodePositionAndTarget(index);
position = positionAndTarget[0];
target = positionAndTarget[1];
}
return {
node: this.tree.settings.nodeDrag,
uid: uid, // dragged node id
target: target, // hovered node
position: position, // before, in, after
command: command // element is copied or moved
}
}
/**
* Returns Array of position and target node
*
* @param {number} index of node which is over mouse
* @returns {Array} [position, target]
* @todo this should be moved into PageTree.js
*/
public setNodePositionAndTarget(index: number): any {
const nodes = this.tree.nodes;
const nodeOver = nodes[index];
const nodeOverDepth = nodeOver.depth;
if (index > 0) {
index--;
}
const nodeBefore = nodes[index];
const nodeBeforeDepth = nodeBefore.depth;
const target = this.tree.nodes[index];
if (nodeBeforeDepth === nodeOverDepth) {
return ['after', target];
} else if (nodeBeforeDepth < nodeOverDepth) {
return ['in', target];
} else {
for (let i = index; i >= 0; i--) {
if (nodes[i].depth === nodeOverDepth) {
return ['after', this.tree.nodes[i]];
} else if (nodes[i].depth < nodeOverDepth) {
return ['in', nodes[i]];
}
}
}
}
}
/*
* 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 {renderHTML} 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(renderHTML(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.settings.nodeOver.node || !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.settings.nodeOver.node
});
}
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;
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.tree.nodeIsOverDelete = false;
this.dropZoneDelete.append('rect')
.attr('height', this.tree.settings.nodeHeight)
.attr('width', '50px')
.attr('x', 0)
.attr('y', 0)
.on('mouseover', () => {
this.tree.nodeIsOverDelete = true;
})
.on('mouseout', () => {
this.tree.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(renderHTML(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.settings.nodeOver.node && this.tree.settings.nodeOver.node.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.settings.nodeOver.node) {
this.dragDrop.addNodeDdClass($nodesWrap, $nodeDd, 'nodrop');