Commit 0ce519a0 authored by Benni Mack's avatar Benni Mack Committed by Richard Haeser
Browse files

[TASK] Use native DOM events instead of d3

This change reduces the overhead of the d3 framework
by using native CustomEvents on the actual Lit element
of the SVG instead of d3-dispatch logic:

* typo3:svg-tree:nodes-prepared (enrich node data)
* typo3:svg-tree:expand-toggle (expand/collapse)
* typo3:svg-tree:node-context (clickmenu)
* typo3:svg-tree:node-selected (on-click)

As shown in the patch, the actual logic of interacting
with the outside framework (Persistent, ContextMenu, window.)
is moved to the actual Lit components, making the SVG
tree slimmer and easier to maintain.

Resolves: #93782
Releases: master
Change-Id: I6f5227579eb16ec218bbe67de2aecbcaaabb3fcc
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68464


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
parent f0a16985
......@@ -14,7 +14,6 @@
import * as d3selection from 'd3-selection';
import {SvgTree, SvgTreeSettings, TreeNodeSelection} from '../../SvgTree';
import {TreeNode} from '../../Tree/TreeNode';
import FormEngineValidation = require('TYPO3/CMS/Backend/FormEngineValidation');
import {customElement} from 'lit-element';
interface SelectTreeSettings extends SvgTreeSettings {
......@@ -27,6 +26,7 @@ interface SelectTreeSettings extends SvgTreeSettings {
@customElement('typo3-backend-form-selecttree')
export class SelectTree extends SvgTree
{
public textPosition: number = 30;
public settings: SelectTreeSettings = {
unselectableElements: [],
exclusiveNodesIdentifiers: '',
......@@ -51,11 +51,7 @@ export class SelectTree extends SvgTree
constructor() {
super();
this.addIcons();
this.dispatch.on('updateNodes.selectTree', (nodes: TreeNodeSelection) => this.updateNodes(nodes));
this.dispatch.on('loadDataAfter.selectTree', () => this.loadDataAfter());
this.dispatch.on('updateSvg.selectTree', (nodes: TreeNodeSelection) => this.renderCheckbox(nodes));
this.dispatch.on('nodeSelectedAfter.selectTree', (node: TreeNode) => this.nodeSelectedAfter(node));
this.dispatch.on('prepareLoadedNode.selectTree', (node: TreeNode) => this.prepareLoadedNode(node));
this.addEventListener('typo3:svg-tree:nodes-prepared', this.prepareLoadedNodes);
}
/**
......@@ -77,7 +73,8 @@ export class SelectTree extends SvgTree
}
/**
* Node selection logic (triggered by different events)
* Node selection logic (triggered by different events) to select multiple
* nodes (unlike SVG Tree itself).
*/
public selectNode(node: TreeNode): void {
if (!this.isNodeSelectable(node)) {
......@@ -95,7 +92,7 @@ export class SelectTree extends SvgTree
node.checked = !checked;
this.dispatch.call('nodeSelectedAfter', this, node);
this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-selected', {detail: {node: node}}));
this.updateVisibleNodes();
}
......@@ -135,19 +132,19 @@ export class SelectTree extends SvgTree
this.showParents(parent);
}
/**
* Check whether node can be selected.
* In some cases (e.g. selecting a parent) it should not be possible to select
* element (as it's own parent).
*/
protected isNodeSelectable(node: TreeNode): boolean {
return !this.settings.readOnlyMode && this.settings.unselectableElements.indexOf(node.identifier) === -1;
}
/**
* Function relays on node.indeterminate state being up to date
*
* Fetches all visible nodes
*/
private updateNodes(nodes: TreeNodeSelection): void {
public updateVisibleNodes(): void {
super.updateVisibleNodes();
const visibleRows = Math.ceil(this.viewportHeight / this.settings.nodeHeight + 1);
const position = Math.floor(Math.max(this.scrollTop - (this.settings.nodeHeight * 2), 0) / this.settings.nodeHeight);
const visibleNodes = this.data.nodes.slice(position, position + visibleRows);
let nodes = this.nodesContainer.selectAll('.node')
.data(visibleNodes, (node: TreeNode) => node.stateIdentifier);
nodes
.selectAll('.tree-check use')
.attr('visibility', function(this: SVGUseElement, node: TreeNode): string {
......@@ -166,17 +163,19 @@ export class SelectTree extends SvgTree
}
/**
* Check if a node has all information to be used.
* Check whether node can be selected.
* In some cases (e.g. selecting a parent) it should not be possible to select
* element (as it's own parent).
*/
private prepareLoadedNode(node: TreeNode): void {
// create stateIdentifier if doesn't exist (for category tree)
if (!node.stateIdentifier) {
const parentId = (node.parents.length) ? node.parents[node.parents.length - 1] : node.identifier;
node.stateIdentifier = parentId + '_' + node.identifier;
}
if (node.selectable === false) {
this.settings.unselectableElements.push(node.identifier);
}
protected isNodeSelectable(node: TreeNode): boolean {
return !this.settings.readOnlyMode && this.settings.unselectableElements.indexOf(node.identifier) === -1;
}
/**
* Add checkbox before the text element
*/
protected appendTextElement(nodes: TreeNodeSelection): TreeNodeSelection {
this.renderCheckbox(nodes);
return super.appendTextElement(nodes)
}
/**
......@@ -185,8 +184,6 @@ export class SelectTree extends SvgTree
* @param {Selection} nodeSelection ENTER selection (only new DOM objects)
*/
private renderCheckbox(nodeSelection: TreeNodeSelection): void {
this.textPosition = 50;
// this can be simplified to single "use" element with changing href on click
// when we drop IE11 on WIN7 support
const g = nodeSelection.filter((node: TreeNode) => {
......@@ -218,70 +215,21 @@ export class SelectTree extends SvgTree
}
/**
* Updates the indeterminate state for ancestors of the current node
*/
private updateAncestorsIndeterminateState(node: TreeNode): void {
// foreach ancestor except node itself
let indeterminate = false;
node.parents.forEach((index: number) => {
const node = this.nodes[index];
node.indeterminate = (node.checked || node.indeterminate || indeterminate);
// check state for the next level
indeterminate = (node.checked || node.indeterminate || node.checked || node.indeterminate);
});
};
/**
* Resets the node.indeterminate for the whole tree.
* It's done once after loading data.
* Later indeterminate state is updated just for the subset of nodes
*/
private loadDataAfter(): void {
this.nodes = this.nodes.map((node: TreeNode) => {
node.indeterminate = false;
return node;
});
this.calculateIndeterminate(this.nodes);
// Initialise "value" attribute of input field after load and revalidate form engine fields
this.saveCheckboxes();
// @todo Unsure if this has ever worked before from `TYPO3.FormEngine.Validation`
FormEngineValidation.validateField(this.settings.input);
};
/**
* Sets indeterminate state for a subtree.
* It relays on the tree to have indeterminate state reset beforehand.
* Check if a node has all information to be used.
* create stateIdentifier if doesn't exist (for category tree)
*/
private calculateIndeterminate(nodes: TreeNode[]): void {
nodes.forEach((node: TreeNode) => {
if ((node.checked || node.indeterminate) && node.parents && node.parents.length > 0) {
node.parents.forEach((parentNodeIndex: number) => {
nodes[parentNodeIndex].indeterminate = true;
});
private prepareLoadedNodes(evt: CustomEvent): void {
let nodes = evt.detail.nodes as Array<TreeNode>;
evt.detail.nodes = nodes.map((node: TreeNode) => {
if (!node.stateIdentifier) {
const parentId = (node.parents.length) ? node.parents[node.parents.length - 1] : node.identifier;
node.stateIdentifier = parentId + '_' + node.identifier;
}
if (node.selectable === false) {
this.settings.unselectableElements.push(node.identifier);
}
return node;
});
};
/**
* Observer for the selectedNode event
*/
private nodeSelectedAfter(node: TreeNode): void {
this.updateAncestorsIndeterminateState(node);
// check all nodes again, to ensure correct display of indeterminate state
this.calculateIndeterminate(this.nodes);
this.saveCheckboxes();
};
/**
* Sets a comma-separated list of selected nodes identifiers to configured input
*/
private saveCheckboxes(): void {
if (typeof this.settings.input === 'undefined') {
return;
}
this.settings.input.value = this.getSelectedNodes()
.map((node: TreeNode): string => node.identifier);
}
/**
......@@ -302,7 +250,6 @@ export class SelectTree extends SvgTree
// current node is not exclusive, but other exclusive node is already selected
this.exclusiveSelectedNode.checked = false;
this.dispatch.call('nodeSelectedAfter', this, this.exclusiveSelectedNode);
this.exclusiveSelectedNode = null;
}
}
......
......@@ -17,6 +17,8 @@ import {html, customElement, LitElement, TemplateResult} from 'lit-element';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import 'TYPO3/CMS/Backend/Element/IconElement';
import './SelectTree';
import FormEngineValidation = require('TYPO3/CMS/Backend/FormEngineValidation');
import {TreeNode} from 'TYPO3/CMS/Backend/Tree/TreeNode';
const toolbarComponentName: string = 'typo3-backend-form-selecttree-toolbar';
......@@ -29,7 +31,9 @@ export class SelectTreeElement {
const treeWrapper = <HTMLElement>document.getElementById(treeWrapperId);
this.tree = document.createElement('typo3-backend-form-selecttree') as SelectTree;
this.tree.classList.add('svg-tree-wrapper');
this.tree.dispatch.on('nodeSelectedAfter.requestUpdate', () => { callback(); } );
this.tree.addEventListener('typo3:svg-tree:nodes-prepared', this.loadDataAfter);
this.tree.addEventListener('typo3:svg-tree:node-selected', this.selectNode);
this.tree.addEventListener('typo3:svg-tree:node-selected', () => { callback(); } );
const settings = {
dataUrl: this.generateRequestUrl(),
......@@ -82,6 +86,70 @@ export class SelectTreeElement {
};
return TYPO3.settings.ajaxUrls.record_tree_data + '&' + new URLSearchParams(params);
}
private selectNode = (evt: CustomEvent) => {
const node = evt.detail.node as TreeNode;
this.updateAncestorsIndeterminateState(node);
// check all nodes again, to ensure correct display of indeterminate state
this.calculateIndeterminate(this.tree.nodes);
this.saveCheckboxes();
}
/**
* Resets the node.indeterminate for the whole tree.
* It's done once after loading data.
* Later indeterminate state is updated just for the subset of nodes
*/
private loadDataAfter = () => {
this.tree.nodes = this.tree.nodes.map((node: TreeNode) => {
node.indeterminate = false;
return node;
});
this.calculateIndeterminate(this.tree.nodes);
// Initialise "value" attribute of input field after load and revalidate form engine fields
this.saveCheckboxes();
// @todo Unsure if this has ever worked before from `TYPO3.FormEngine.Validation`
FormEngineValidation.validateField(this.recordField);
}
/**
* Sets a comma-separated list of selected nodes identifiers to configured input
*/
private saveCheckboxes = (): void => {
if (typeof this.recordField === 'undefined') {
return;
}
this.recordField.value = this.tree.getSelectedNodes().map((node: TreeNode): string => node.identifier).join(',');
}
/**
* Updates the indeterminate state for ancestors of the current node
*/
private updateAncestorsIndeterminateState(node: TreeNode): void {
// foreach ancestor except node itself
let indeterminate = false;
node.parents.forEach((index: number) => {
const node = this.tree.nodes[index];
node.indeterminate = (node.checked || node.indeterminate || indeterminate);
// check state for the next level
indeterminate = (node.checked || node.indeterminate || node.checked || node.indeterminate);
});
}
/**
* Sets indeterminate state for a subtree.
* It relays on the tree to have indeterminate state reset beforehand.
*/
private calculateIndeterminate(nodes: TreeNode[]): void {
nodes.forEach((node: TreeNode) => {
if ((node.checked || node.indeterminate) && node.parents && node.parents.length > 0) {
node.parents.forEach((parentNodeIndex: number) => {
nodes[parentNodeIndex].indeterminate = true;
});
}
});
}
}
@customElement(toolbarComponentName)
......
......@@ -16,8 +16,6 @@ import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {SvgTree, TreeNodeSelection} from '../SvgTree';
import {TreeNode} from '../Tree/TreeNode';
import {PageTreeDragDrop, PageTreeNodeDragHandler} from './PageTreeDragDrop';
import ContextMenu = require('../ContextMenu');
import Persistent from '../Storage/Persistent';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {KeyTypesEnum as KeyTypes} from '../Enum/KeyTypes';
import {customElement} from 'lit-element';
......@@ -54,9 +52,6 @@ export class PageTree extends SvgTree
readableRootline: '',
isMountPoint: false,
};
this.dispatch.on('nodeSelectedAfter.pageTree', (node: TreeNode) => this.nodeSelectedAfter(node));
this.dispatch.on('nodeRightClick.pageTree', (node: TreeNode) => this.nodeRightClick(node));
this.dispatch.on('prepareLoadedNode.pageTree', (node: TreeNode) => this.prepareLoadedNode(node));
}
public sendChangeCommand(data: any): void {
......@@ -112,37 +107,11 @@ export class PageTree extends SvgTree
});
}
public nodeRightClick(node: TreeNode): void {
ContextMenu.show(
node.itemType,
parseInt(node.identifier, 10),
'tree',
'',
'',
this.getNodeElement(node)
);
}
/**
* Event listener called for each loaded node,
* here used to mark node remembered in fsMode as selected
*/
public prepareLoadedNode(node: TreeNode) {
if (node.stateIdentifier === top.window.fsMod.navFrameHighlightedID.web) {
node.checked = true;
}
}
public hideChildren(node: TreeNode) {
super.hideChildren(node);
Persistent.set('BackendComponents.States.Pagetree.stateHash.' + node.stateIdentifier, '0');
}
public showChildren(node: TreeNode) {
this.loadChildrenOfNode(node);
super.showChildren(node);
Persistent.set('BackendComponents.States.Pagetree.stateHash.' + node.stateIdentifier, '1');
}
public updateNodeBgClass(nodeBg: TreeNodeSelection) {
return super.updateNodeBgClass.call(this, nodeBg).call(this.initializeDragForNode());
}
......@@ -163,21 +132,6 @@ export class PageTree extends SvgTree
return nodes;
}
/**
* Node selection logic (triggered by different events)
* Page tree supports only one node to be selected at a time
* so the default function from SvgTree needs to be overridden
*/
public selectNode(node: TreeNode) {
if (!this.isNodeSelectable(node)) {
return;
}
// Disable already selected nodes
this.disableSelectedNodes();
node.checked = true;
this.dispatch.call('nodeSelectedAfter', this, node);
this.updateVisibleNodes();
}
/**
* Make the DOM element of the node given as parameter focusable and focus it
......@@ -248,29 +202,6 @@ export class PageTree extends SvgTree
});
}
/**
* Observer for the selectedNode event
*/
protected nodeSelectedAfter(node: TreeNode) {
if (!node.checked) {
return;
}
//remember the selected page in the global state
top.window.fsMod.recentIds.web = node.identifier;
top.window.fsMod.currentBank = node.stateIdentifier.split('_')[0];
top.window.fsMod.navFrameHighlightedID.web = node.stateIdentifier;
let separator = '?';
if (top.window.currentSubScript.indexOf('?') !== -1) {
separator = '&';
}
top.TYPO3.Backend.ContentContainer.setUrl(
top.window.currentSubScript + separator + 'id=' + node.identifier
);
}
/**
* Event handler for double click on a node's label
* Changed text position if there is 'stop page tree' option
......
......@@ -21,6 +21,8 @@ import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {select as d3select} from 'd3-selection';
import DebounceEvent from 'TYPO3/CMS/Core/Event/DebounceEvent';
import Persistent from 'TYPO3/CMS/Backend/Storage/Persistent';
import ContextMenu = require('../ContextMenu');
import {TreeNode} from './../Tree/TreeNode';
import 'TYPO3/CMS/Backend/Element/IconElement';
import 'TYPO3/CMS/Backend/Input/Clearable';
......@@ -106,6 +108,10 @@ export class PageTreeNavigationComponent extends LitElement {
const dragDrop = new PageTreeDragDrop(this.tree);
this.tree.dragDrop = dragDrop;
this.toolbar.tree = this.tree;
this.tree.addEventListener('typo3:svg-tree:expand-toggle', this.toggleExpandState);
this.tree.addEventListener('typo3:svg-tree:node-selected', this.loadContent);
this.tree.addEventListener('typo3:svg-tree:node-context', this.showContextMenu);
this.tree.addEventListener('typo3:svg-tree:nodes-prepared', this.selectActiveNode);
}
return html`
......@@ -187,6 +193,63 @@ export class PageTreeNavigationComponent extends LitElement {
this.tree.errorNotification(error, true);
});
}
private toggleExpandState = (evt: CustomEvent): void => {
const node = evt.detail.node as TreeNode;
if (node) {
Persistent.set('BackendComponents.States.Pagetree.stateHash.' + node.stateIdentifier, (node.expanded ? '1' : '0'));
}
}
private loadContent = (evt: CustomEvent): void => {
const node = evt.detail.node as TreeNode;
if (!node?.checked) {
return;
}
//remember the selected page in the global state
top.window.fsMod.recentIds.web = node.identifier;
top.window.fsMod.currentBank = node.stateIdentifier.split('_')[0];
top.window.fsMod.navFrameHighlightedID.web = node.stateIdentifier;
let separator = '?';
if (top.window.currentSubScript.indexOf('?') !== -1) {
separator = '&';
}
top.TYPO3.Backend.ContentContainer.setUrl(
top.window.currentSubScript + separator + 'id=' + node.identifier
);
}
private showContextMenu = (evt: CustomEvent): void => {
const node = evt.detail.node as TreeNode;
if (!node) {
return;
}
ContextMenu.show(
node.itemType,
parseInt(node.identifier, 10),
'tree',
'',
'',
this.tree.getNodeElement(node)
);
}
/**
* Event listener called for each loaded node,
* here used to mark node remembered in fsMod as selected
*/
private selectActiveNode = (evt: CustomEvent): void => {
const selectedNodeIdentifier = window.fsMod.navFrameHighlightedID.web;
let nodes = evt.detail.nodes as Array<TreeNode>;
evt.detail.nodes = nodes.map((node: TreeNode) => {
if (node.stateIdentifier === selectedNodeIdentifier) {
node.checked = true;
}
return node;
});
}
}
@customElement(toolbarComponentName)
......
......@@ -14,7 +14,6 @@
import {html, property, internalProperty, LitElement, TemplateResult} from 'lit-element';
import {TreeNode} from './Tree/TreeNode';
import * as d3selection from 'd3-selection';
import * as d3dispatch from 'd3-dispatch';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import Notification = require('./Notification');
import {KeyTypesEnum as KeyTypes} from './Enum/KeyTypes';
......@@ -65,11 +64,6 @@ export class SvgTree extends LitElement {
expandUpToLevel: null as any,
};
/**
* D3 event dispatcher
*/
public dispatch: d3dispatch.Dispatch<any> = null;
/**
* Check if cursor is over the SVG element
*/
......@@ -102,7 +96,7 @@ export class SvgTree extends LitElement {
public nodes: TreeNode[] = [];
public textPosition: number = 0;
public textPosition: number = 10;
protected icons: {[keys: string]: SvgTreeDataIcon} = {};
......@@ -134,24 +128,15 @@ export class SvgTree extends LitElement {
protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle;
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
constructor() {
super();
this.dispatch = d3dispatch.dispatch(
'updateNodes',
'updateSvg',
'loadDataAfter',
'prepareLoadedNode',
'nodeSelectedAfter',
'nodeRightClick'
);
}
/**
* Initializes the tree component - created basic markup, loads and renders data
* @todo declare private
*/
public doSetup(settings: any): void {
Object.assign(this.settings, settings);
if (this.settings.showIcons) {
this.textPosition += 20;
}
this.svg = d3selection.select(this).select('svg');
this.container = this.svg.select('.nodes-wrapper') as TreeWrapperSelection<SVGGElement>;
......@@ -224,7 +209,6 @@ export class SvgTree extends LitElement {
*/
public replaceData(nodes: TreeNode[]) {
this.setParametersNode(nodes);
this.dispatch.call('loadDataAfter', this);
this.prepareDataForVisibleNodes();
this.nodesContainer.selectAll('.node').remove();
this.nodesBgContainer.selectAll('.node-bg').remove();
......@@ -262,9 +246,6 @@ export class SvgTree extends LitElement {
if (typeof node.checked === 'undefined') {
node.checked = false;