Commit 59717d88 authored by Benni Mack's avatar Benni Mack Committed by Richard Haeser
Browse files

[TASK] Clean up SVG tree implementations

This change moves all related drag+drop code for the
navigation components into each TypeScript class, making
clear that some functionality is only meant to be used in

This change:
* Removes the DragDrop / Actions files and moves them into the NavigationComponent.ts files
* Moves all drag+drop related functionality into proper extended EditablePageTree/EditableFileStorageTree
  classes.

This is a pre-patch to use the actual PageTree/FileStorageTree
classes in other contexts (such as Element Browser) and extend
them, and only have the functionality that is needed for all places.

Resolves: #93785
Releases: master
Change-Id: Ifff4ba2888ed498169580b912263d0dc0914886f
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68520


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
parent c560f1d0
......@@ -11,24 +11,17 @@
* The TYPO3 project - inspiring people to share!
*/
import * as d3selection from 'd3-selection';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {SvgTree, TreeNodeSelection} from '../SvgTree';
import {TreeNode} from '../Tree/TreeNode';
import {PageTreeDragDrop, PageTreeNodeDragHandler} from './PageTreeDragDrop';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {KeyTypesEnum as KeyTypes} from '../Enum/KeyTypes';
import {customElement} from 'lit-element';
/**
* A Tree based on SVG for pages, which has a AJAX-based loading of the tree
* and also handles search + filter via AJAX.
*/
@customElement('typo3-backend-page-tree')
export class PageTree extends SvgTree
{
public nodeIsEdit: boolean;
public dragDrop: PageTreeDragDrop;
protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle;
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
......@@ -54,70 +47,13 @@ export class PageTree extends SvgTree
};
}
public sendChangeCommand(data: any): void {
let params = '';
let targetUid = 0;
if (data.target) {
targetUid = data.target.identifier;
if (data.position === 'after') {
targetUid = -targetUid;
}
}
if (data.command === 'new') {
params = '&data[pages][NEW_1][pid]=' + targetUid +
'&data[pages][NEW_1][title]=' + encodeURIComponent(data.name) +
'&data[pages][NEW_1][doktype]=' + data.type;
} else if (data.command === 'edit') {
params = '&data[pages][' + data.uid + '][' + data.nameSourceField + ']=' + encodeURIComponent(data.title);
} else {
if (data.command === 'delete') {
if (data.uid === window.fsMod.recentIds.web) {
this.selectNode(this.nodes[0]);
}
params = '&cmd[pages][' + data.uid + '][delete]=1';
} else {
params = 'cmd[pages][' + data.uid + '][' + data.command + ']=' + targetUid;
}
}
this.nodesAddPlaceholder();
(new AjaxRequest(top.TYPO3.settings.ajaxUrls.record_process))
.post(params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'},
})
.then((response) => {
return response.resolve();
})
.then((response) => {
if (response && response.hasErrors) {
this.errorNotification(response.messages, false);
this.nodesContainer.selectAll('.node').remove();
this.updateVisibleNodes();
this.nodesRemovePlaceholder();
} else {
this.refreshOrFilterTree();
}
})
.catch((error) => {
this.errorNotification(error);
});
}
public showChildren(node: TreeNode) {
this.loadChildrenOfNode(node);
super.showChildren(node);
}
public updateNodeBgClass(nodeBg: TreeNodeSelection) {
return super.updateNodeBgClass.call(this, nodeBg).call(this.initializeDragForNode());
}
public nodesUpdate(nodes: TreeNodeSelection) {
nodes = super.nodesUpdate.call(this, nodes).call(this.initializeDragForNode());
public nodesUpdate(nodes: TreeNodeSelection): TreeNodeSelection {
nodes = super.nodesUpdate(nodes);
nodes
.append('text')
.text('+')
......@@ -128,45 +64,11 @@ export class PageTree extends SvgTree
.on('click', (evt: MouseEvent, node: TreeNode) => {
document.dispatchEvent(new CustomEvent('typo3:pagetree:mountPoint', {detail: {pageId: parseInt(node.identifier, 10)}}));
});
return nodes;
}
/**
* Make the DOM element of the node given as parameter focusable and focus it
*/
public switchFocusNode(node: TreeNode) {
// Focus node only if it's not currently in edit mode
if (!this.nodeIsEdit) {
this.switchFocus(this.getNodeElement(node));
}
}
/**
* Initializes a drag&drop when called on the page tree. Should be moved somewhere else at some point
*/
public initializeDragForNode() {
return this.dragDrop.connectDragHandler(new PageTreeNodeDragHandler(this, this.dragDrop))
}
public removeEditedText() {
const inputWrapper = d3selection.selectAll('.node-edit');
if (inputWrapper.size()) {
try {
inputWrapper.remove();
this.nodeIsEdit = false;
} catch (e) {
// ...
}
}
}
/**
* Loads child nodes via Ajax (used when expanding a collapsed node)
*
* @param parentNode
* @return {boolean}
*/
protected loadChildrenOfNode(parentNode: TreeNode) {
if (parentNode.loaded) {
......@@ -203,11 +105,9 @@ export class PageTree extends SvgTree
}
/**
* Event handler for double click on a node's label
* Changed text position if there is 'stop page tree' option
*/
protected appendTextElement(nodes: TreeNodeSelection): TreeNodeSelection {
let clicks = 0;
return super.appendTextElement(nodes)
.attr('dx', (node) => {
let position = this.textPosition;
......@@ -218,103 +118,6 @@ export class PageTree extends SvgTree
position += 15;
}
return position;
})
.on('click', (event, node: TreeNode) => {
if (node.identifier === '0') {
this.selectNode(node);
return;
}
if (++clicks === 1) {
setTimeout(() => {
if (clicks === 1) {
this.selectNode(node);
} else {
this.editNodeLabel(node);
}
clicks = 0;
}, 300);
}
});
};
private sendEditNodeLabelCommand(node: TreeNode) {
const params = '&data[pages][' + node.identifier + '][' + node.nameSourceField + ']=' + encodeURIComponent(node.newName);
// remove old node from svg tree
this.nodesAddPlaceholder(node);
(new AjaxRequest(top.TYPO3.settings.ajaxUrls.record_process))
.post(params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'},
})
.then((response) => {
return response.resolve();
})
.then((response) => {
if (response && response.hasErrors) {
this.errorNotification(response.messages, false);
} else {
node.name = node.newName;
}
this.refreshOrFilterTree();
})
.catch((error) => {
this.errorNotification(error, true);
});
}
private editNodeLabel(node: TreeNode) {
if (!node.allowEdit) {
return;
}
this.removeEditedText();
this.nodeIsEdit = true;
d3selection.select(this.svg.node().parentNode as HTMLElement)
.append('input')
.attr('class', 'node-edit')
.style('top', () => {
const top = node.y + this.settings.marginTop;
return top + 'px';
})
.style('left', (node.x + this.textPosition + 5) + 'px')
.style('width', this.settings.width - (node.x + this.textPosition + 20) + 'px')
.style('height', this.settings.nodeHeight + 'px')
.attr('type', 'text')
.attr('value', node.name)
.on('keydown', (event: KeyboardEvent) => {
// @todo Migrate to `evt.code`, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
const code = event.keyCode;
if (code === KeyTypes.ENTER || code === KeyTypes.TAB) {
const target = event.target as HTMLInputElement;
const newName = target.value.trim();
this.nodeIsEdit = false;
this.removeEditedText();
if (newName.length && (newName !== node.name)) {
node.nameSourceField = node.nameSourceField || 'title';
node.newName = newName;
this.sendEditNodeLabelCommand(node);
}
} else if (code === KeyTypes.ESCAPE) {
this.nodeIsEdit = false;
this.removeEditedText();
}
})
.on('blur', (evt: FocusEvent) => {
if (!this.nodeIsEdit) {
return;
}
const target = evt.target as HTMLInputElement;
const newName = target.value.trim();
if (newName.length && (newName !== node.name)) {
node.nameSourceField = node.nameSourceField || 'title';
node.newName = newName;
this.sendEditNodeLabelCommand(node);
}
this.removeEditedText();
})
.node()
.select();
}
}
/*
* 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!
*/
import {DragDrop, DragDropHandler, DraggablePositionEnum} from '../Tree/DragDrop';
import {D3DragEvent} from 'd3-drag';
import {TreeNode} from '../Tree/TreeNode';
import * as d3selection from 'd3-selection';
import Modal = require('../Modal');
import Severity = require('../Severity');
import {TreeWrapperSelection} from '../SvgTree';
import {PageTree} from './PageTree';
type TreeNodeDragEvent = D3DragEvent<SVGElement, any, TreeNode>;
interface NodeCreationOptions {
type: string,
name: string,
title?: string;
tooltip: string,
icon: string,
position: DraggablePositionEnum,
target: TreeNode
}
interface NodePositionOptions {
node: TreeNode,
target: TreeNode,
uid: string,
position: DraggablePositionEnum,
command: string
}
interface NodeTargetPosition {
target: TreeNode,
position: DraggablePositionEnum
}
/**
* Extends Drag&Drop functionality for Page Tree positioning when dropping
*/
export class PageTreeDragDrop extends DragDrop {
public changeNodePosition(droppedNode: TreeNode, command: string = ''): null|NodePositionOptions {
const nodes = this.tree.nodes;
const uid = this.tree.settings.nodeDrag.identifier;
let position = this.tree.settings.nodeDragPosition;
let target = droppedNode || this.tree.settings.nodeDrag;
if (uid === target.identifier && command !== 'delete') {
return null;
}
if (position === DraggablePositionEnum.BEFORE) {
const index = nodes.indexOf(droppedNode);
const positionAndTarget = this.setNodePositionAndTarget(index);
if (positionAndTarget === null) {
return null;
}
position = positionAndTarget.position;
target = positionAndTarget.target;
}
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): null|NodeTargetPosition {
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 {position: DraggablePositionEnum.AFTER, target};
} else if (nodeBeforeDepth < nodeOverDepth) {
return {position: DraggablePositionEnum.INSIDE, target};
} else {
for (let i = index; i >= 0; i--) {
if (nodes[i].depth === nodeOverDepth) {
return {position: DraggablePositionEnum.AFTER, target: this.tree.nodes[i]};
} else if (nodes[i].depth < nodeOverDepth) {
return {position: DraggablePositionEnum.AFTER, target: nodes[i]};
}
}
}
return null;
}
}
/**
* 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: PageTreeDragDrop;
private tree: PageTree;
constructor(item: any, tree: any, dragDrop: PageTreeDragDrop) {
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: TreeNodeDragEvent): boolean {
this.isDragged = false;
this.startDrag = false;
this.startPageX = event.sourceEvent.pageX;
this.startPageY = event.sourceEvent.pageY;
return true;
}
public dragDragged(event: TreeNodeDragEvent): boolean {
if (this.dragDrop.isDragNodeDistanceMore(event, this)) {
this.startDrag = true;
} else {
return false;
}
// Add the draggable element
if (this.isDragged === false) {
this.isDragged = true;
this.dragDrop.createDraggable('#icon-' + this.icon, this.name);
}
this.dragDrop.openNodeTimeout();
this.dragDrop.updateDraggablePosition(event);
this.dragDrop.changeNodeClasses(event);
return true;
}
public dragEnd(event: TreeNodeDragEvent): boolean {
if (!this.startDrag) {
return false;
}
this.isDragged = false;
this.dragDrop.removeNodeDdClass();
if (this.tree.settings.allowDragMove !== true || !this.tree.hoveredNode || !this.tree.isOverSvg) {
return false;
}
if (this.tree.settings.canNodeDrag) {
this.addNewNode({
type: this.id,
name: this.name,
tooltip: this.tooltip,
icon: this.icon,
position: this.tree.settings.nodeDragPosition,
target: this.tree.hoveredNode
});
}
return true;
}
/**
* Add new node to the tree (used in drag+drop)
*
* @type {Object} options
* @private
*/
private addNewNode(options: NodeCreationOptions): void {
const target = options.target;
let index = this.tree.nodes.indexOf(target);
const newNode = {} as TreeNode;
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.tree.nodeIsEdit = true;
if (options.position === DraggablePositionEnum.INSIDE) {
newNode.depth++;
newNode.parents.unshift(index);
newNode.parentsStateIdentifier.unshift(this.tree.nodes[index].stateIdentifier);
this.tree.nodes[index].hasChildren = true;
this.tree.showChildren(this.tree.nodes[index]);
}
if (options.position === DraggablePositionEnum.INSIDE || options.position === DraggablePositionEnum.AFTER) {
index++;
}
if (options.icon) {
newNode.icon = options.icon;
}
if (newNode.position === DraggablePositionEnum.AFTER) {
const positionAndTarget = this.dragDrop.setNodePositionAndTarget(index);
// @todo Check whether an error should be thrown in case of `null`
if (positionAndTarget !== null) {
newNode.position = positionAndTarget.position;
newNode.target = positionAndTarget.target;
}
}
this.tree.nodes.splice(index, 0, newNode);
this.tree.setParametersNode();
this.tree.prepareDataForVisibleNodes();
this.tree.updateVisibleNodes();
this.tree.removeEditedText();
d3selection.select(this.tree.svg.node().parentNode as HTMLElement)
.append('input')
.attr('class', 'node-edit')
.style('top', newNode.y + this.tree.settings.marginTop + 'px')
.style('left', newNode.x + this.tree.textPosition + 5 + 'px')
.style('width', this.tree.settings.width - (newNode.x + this.tree.textPosition + 20) + 'px')
.style('height', this.tree.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.tree.nodeIsEdit = false;
const newName = target.value.trim();
if (newName.length) {
newNode.name = newName;
this.tree.removeEditedText();
this.tree.sendChangeCommand(newNode);
} else {
this.removeNode(newNode);
}
} else if (code === 27) { // esc
this.tree.nodeIsEdit = false;
this.removeNode(newNode);
}
})
.on('blur', (evt: FocusEvent) => {
if (this.tree.nodeIsEdit && (this.tree.nodes.indexOf(newNode) > -1)) {
const target = evt.target as HTMLInputElement;
const newName = target.value.trim();
if (newName.length) {
newNode.name = newName;
this.tree.removeEditedText();
this.tree.sendChangeCommand(newNode);
} else {
this.removeNode(newNode);
}
}
})
.node()
.select();
}
private removeNode(newNode: TreeNode) {
let index = this.tree.nodes.indexOf(newNode);
// if newNode is only one child
if (this.tree.nodes[index - 1].depth != newNode.depth
&& (!this.tree.nodes[index + 1] || this.tree.nodes[index + 1].depth != newNode.depth)) {
this.tree.nodes[index - 1].hasChildren = false;
}
this.tree.nodes.splice(index, 1);
this.tree.setParametersNode();
this.tree.prepareDataForVisibleNodes();