Commit 1b2d7a4a authored by Benni Mack's avatar Benni Mack Committed by Georg Ringer
Browse files

[TASK] Move SVG Tree to Lit Elements



The SVG Tree class is now a lit element, allowing for further
reduction of d3 usage in favor of native HTML5 APIs.

Resolves: #93773
Releases: master
Signed-off-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Signed-off-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Change-Id: I12fef793726f83e872a353901528516a589e48ab
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68309

Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
parent d9f56ccf
......@@ -64,9 +64,20 @@ $svgColors: (
}
}
.svg-tree-element {
display: flex;
flex-direction: column;
& > .svg-tree-wrapper {
flex: 1 0 auto;
}
}
.svg-tree-wrapper {
display: block;
position: relative;
overflow-y: scroll;
flex-direction: column;
& > svg {
margin-top: 15px;
......
......@@ -15,6 +15,7 @@ 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 {
exclusiveNodesIdentifiers: '';
......@@ -23,6 +24,7 @@ interface SelectTreeSettings extends SvgTreeSettings {
readOnlyMode: false
}
@customElement('typo3-backend-form-selecttree')
export class SelectTree extends SvgTree
{
public settings: SelectTreeSettings = {
......
......@@ -11,23 +11,24 @@
* The TYPO3 project - inspiring people to share!
*/
import {SelectTree} from './SelectTree';
import type {SelectTree} from './SelectTree';
import {Tooltip} from 'bootstrap';
import {html, customElement, LitElement, TemplateResult} from 'lit-element';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import 'TYPO3/CMS/Backend/Element/IconElement';
import './SelectTree';
const toolbarComponentName: string = 'typo3-backend-form-selecttree-toolbar';
export class SelectTreeElement {
private readonly treeWrapper: HTMLElement = null;
private readonly recordField: HTMLInputElement = null;
private readonly tree: SelectTree = null;
constructor(treeWrapperId: string, treeRecordFieldId: string, callback: Function) {
this.treeWrapper = <HTMLElement>document.getElementById(treeWrapperId);
this.recordField = <HTMLInputElement>document.getElementById(treeRecordFieldId);
this.tree = new SelectTree();
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(); } );
const settings = {
......@@ -39,12 +40,13 @@ export class SelectTreeElement {
expandUpToLevel: this.recordField.dataset.treeExpandUpToLevel,
unselectableElements: [] as Array<any>
};
this.treeWrapper.addEventListener('svg-tree:initialized', () => {
this.tree.addEventListener('svg-tree:initialized', () => {
const toolbarElement = document.createElement(toolbarComponentName) as TreeToolbar;
toolbarElement.tree = this.tree;
this.treeWrapper.prepend(toolbarElement);
this.tree.prepend(toolbarElement);
});
this.tree.initialize(this.treeWrapper, settings);
this.tree.setup = settings;
treeWrapper.append(this.tree);
this.listenForVisibleTree();
}
......@@ -53,12 +55,12 @@ export class SelectTreeElement {
* becomes visible.
*/
private listenForVisibleTree(): void {
if (!this.treeWrapper.offsetParent) {
if (!this.tree.offsetParent) {
// Search for the parents that are tab containers
let idOfTabContainer = this.treeWrapper.closest('.tab-pane').getAttribute('id');
let idOfTabContainer = this.tree.closest('.tab-pane').getAttribute('id');
if (idOfTabContainer) {
let btn = document.querySelector('[aria-controls="' + idOfTabContainer + '"]');
btn.addEventListener('shown.bs.tab', () => { this.treeWrapper.dispatchEvent(new Event('svg-tree:visible')); });
btn.addEventListener('shown.bs.tab', () => { this.tree.dispatchEvent(new Event('svg-tree:visible')); });
}
}
}
......
......@@ -20,17 +20,19 @@ 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';
/**
* 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;
private dragDrop: PageTreeDragDrop;
public constructor() {
super();
......@@ -57,11 +59,6 @@ export class PageTree extends SvgTree
this.dispatch.on('prepareLoadedNode.pageTree', (node: TreeNode) => this.prepareLoadedNode(node));
}
public initialize(selector: HTMLElement, settings: any, dragDrop?: PageTreeDragDrop) {
super.initialize(selector, settings);
this.dragDrop = dragDrop;
}
public sendChangeCommand(data: any): void {
let params = '';
let targetUid = 0;
......
......@@ -11,7 +11,8 @@
* The TYPO3 project - inspiring people to share!
*/
import {html, customElement, property, query, LitElement, TemplateResult} from 'lit-element';
import {html, customElement, property, query, LitElement, TemplateResult, PropertyValues} from 'lit-element';
import {until} from 'lit-html/directives/until';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import {PageTree} from './PageTree';
import {PageTreeDragDrop, ToolbarDragHandler} from './PageTreeDragDrop';
......@@ -19,8 +20,9 @@ import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {select as d3select} from 'd3-selection';
import DebounceEvent from 'TYPO3/CMS/Core/Event/DebounceEvent';
import 'TYPO3/CMS/Backend/Element/IconElement';
import Persistent from 'TYPO3/CMS/Backend/Storage/Persistent';
import 'TYPO3/CMS/Backend/Element/IconElement';
import 'TYPO3/CMS/Backend/Input/Clearable';
/**
* This module defines the Custom Element for rendering the navigation component for an editable page tree
......@@ -35,19 +37,19 @@ import Persistent from 'TYPO3/CMS/Backend/Storage/Persistent';
export const navigationComponentName: string = 'typo3-backend-navigation-component-pagetree';
const toolbarComponentName: string = 'typo3-backend-navigation-component-pagetree-toolbar';
interface Configuration {
[keys: string]: any;
}
@customElement(navigationComponentName)
export class PageTreeNavigationComponent extends LitElement {
@property({type: String}) mountPointPath: string = null;
// @todo: Migrate svg-tree-wrapper into a custom element
@query('.svg-tree-wrapper') treeWrapper: HTMLElement;
private readonly tree: PageTree = null;
@query('.svg-tree-wrapper') tree: PageTree;
@query(toolbarComponentName) toolbar: Toolbar;
public constructor() {
super();
this.tree = new PageTree();
}
private configuration: Configuration = null;
connectedCallback(): void {
super.connectedCallback();
......@@ -71,47 +73,64 @@ export class PageTreeNavigationComponent extends LitElement {
protected render(): TemplateResult {
return html`
<div id="typo3-pagetree" class="svg-tree">
<div>
<div id="typo3-pagetree-toolbar" class="svg-toolbar">
<typo3-backend-navigation-component-pagetree-toolbar .tree="${this.tree}"></typo3-backend-navigation-component-pagetree-toolbar>
</div>
<div id="typo3-pagetree-treeContainer" class="navigation-tree-container">
${this.renderMountPoint()}
<div id="typo3-pagetree-tree" class="svg-tree-wrapper"></div>
</div>
</div>
<div class="svg-tree-loader">
<typo3-backend-icon identifier="spinner-circle-light" size="large"></typo3-backend-icon>
</div>
${until(this.renderTree(), this.renderLoader())}
</div>
`;
}
protected firstUpdated() {
this.treeWrapper.dispatchEvent(new Event('svg-tree:visible'));
protected getConfiguration(): Promise<Configuration> {
if (this.configuration !== null) {
return Promise.resolve(this.configuration);
}
const configurationUrl = top.TYPO3.settings.ajaxUrls.page_tree_configuration;
(new AjaxRequest(configurationUrl)).get()
.then(async (response: AjaxResponse): Promise<void> => {
return (new AjaxRequest(configurationUrl)).get()
.then(async (response: AjaxResponse): Promise<Configuration> => {
const configuration = await response.resolve('json');
Object.assign(configuration, {
dataUrl: top.TYPO3.settings.ajaxUrls.page_tree_data,
filterUrl: top.TYPO3.settings.ajaxUrls.page_tree_filter,
showIcons: true
});
const dragDrop = new PageTreeDragDrop(this.tree);
this.configuration = configuration;
this.mountPointPath = configuration.temporaryMountPoint || null;
return configuration;
});
}
protected renderTree(): Promise<TemplateResult> {
return this.getConfiguration()
.then((configuration: Configuration): TemplateResult => {
// Initialize the toolbar once the tree was rendered
this.treeWrapper.addEventListener('svg-tree:initialized', () => {
// set up toolbar now with updated settings
const toolbar = this.querySelector(toolbarComponentName) as Toolbar;
toolbar.requestUpdate('tree').then(() => toolbar.initializeDragDrop(dragDrop));
if (configuration.temporaryMountPoint) {
this.mountPointPath = configuration.temporaryMountPoint;
}
});
this.tree.initialize(this.treeWrapper, configuration, dragDrop);
const initialized = () => {
const dragDrop = new PageTreeDragDrop(this.tree);
this.tree.dragDrop = dragDrop;
this.toolbar.tree = this.tree;
}
return html`
<div>
<div id="typo3-pagetree-toolbar" class="svg-toolbar">
<typo3-backend-navigation-component-pagetree-toolbar .tree="${this.tree}"></typo3-backend-navigation-component-pagetree-toolbar>
</div>
<div id="typo3-pagetree-treeContainer" class="navigation-tree-container">
${this.renderMountPoint()}
<typo3-backend-page-tree id="typo3-pagetree-tree" class="svg-tree-wrapper" .setup=${configuration} @svg-tree:initialized=${initialized}></typo3-backend-page-tree>
</div>
</div>
${this.renderLoader()}
`;
});
}
protected renderLoader(): TemplateResult {
return html`
<div class="svg-tree-loader">
<typo3-backend-icon identifier="spinner-circle-light" size="large"></typo3-backend-icon>
</div>
`;
}
private refresh = (): void => {
this.tree.refreshOrFilterTree();
}
......@@ -181,7 +200,7 @@ class Toolbar extends LitElement {
public initializeDragDrop(dragDrop: PageTreeDragDrop): void
{
if (this.tree.settings?.doktypes?.length) {
if (this.tree?.settings?.doktypes?.length) {
this.tree.settings.doktypes.forEach((item: any) => {
if (item.icon) {
const htmlElement = this.querySelector('[data-tree-icon="' + item.icon + '"]');
......@@ -215,6 +234,14 @@ class Toolbar extends LitElement {
}
}
protected updated(changedProperties: PropertyValues): void {
changedProperties.forEach((oldValue, propName) => {
if (propName === 'tree' && this.tree !== null) {
this.initializeDragDrop(this.tree.dragDrop);
}
});
}
protected render(): TemplateResult {
/* eslint-disable @typescript-eslint/indent */
return html`
......@@ -228,7 +255,7 @@ class Toolbar extends LitElement {
</button>
</div>
<div class="svg-toolbar__submenu">
${this.tree.settings?.doktypes?.length
${this.tree?.settings?.doktypes?.length
? this.tree.settings.doktypes.map((item: any) => {
return html`
<div class="svg-toolbar__drag-node" data-tree-icon="${item.icon}" data-node-type="${item.nodeType}"
......
......@@ -11,8 +11,7 @@
* The TYPO3 project - inspiring people to share!
*/
import {html, TemplateResult} from 'lit-element';
import {renderNodes} from 'TYPO3/CMS/Core/lit-helper';
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';
......@@ -24,7 +23,7 @@ import Tooltip = require('./Tooltip');
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {MarkupIdentifiers} from './Enum/IconTypes';
export type TreeWrapperSelection<TBase extends d3selection.BaseType> = d3selection.Selection<TBase, any, SvgTreeWrapper, any>;
export type TreeWrapperSelection<TBase extends d3selection.BaseType> = d3selection.Selection<TBase, any, any, any>;
export type TreeNodeSelection = d3selection.Selection<d3selection.BaseType, TreeNode, any, any>;
interface SvgTreeData {
......@@ -51,7 +50,20 @@ export interface SvgTreeWrapper extends HTMLElement {
svgtree?: SvgTree
}
export class SvgTree {
export class SvgTree extends LitElement {
@property({type: Object}) setup?: {[keys: string]: any} = null;
@internalProperty() settings: SvgTreeSettings = {
showIcons: false,
marginTop: 15,
nodeHeight: 20,
indentWidth: 16,
width: 300,
duration: 400,
dataUrl: '',
filterUrl: '',
defaultProperties: {},
expandUpToLevel: null as any,
};
/**
* D3 event dispatcher
......@@ -90,22 +102,9 @@ export class SvgTree {
public nodes: TreeNode[] = [];
public settings: SvgTreeSettings = {
showIcons: false,
marginTop: 15,
nodeHeight: 20,
indentWidth: 16,
width: 300,
duration: 400,
dataUrl: '',
filterUrl: '',
defaultProperties: {},
expandUpToLevel: null as any,
};
public textPosition: number = 0;
protected icons: {[keys: string]: SvgTreeDataIcon};
protected icons: {[keys: string]: SvgTreeDataIcon} = {};
/**
* SVG <defs> container wrapping all icon definitions
......@@ -124,13 +123,7 @@ export class SvgTree {
nodes: TreeNode[] = [];
};
/**
* HTMLElement (`<div>`) of the wrapper holding the `<svg>` element.
* Height of this wrapper is important (we only render as many nodes as fit in the wrapper
*/
protected wrapper: SvgTreeWrapper = null;
protected viewportHeight: number = 0;
protected scrollTop: number = 0;
protected scrollBottom: number = 0;
protected searchTerm: string|null = null;
protected unfilteredNodes: string = '';
......@@ -142,6 +135,7 @@ export class SvgTree {
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
constructor() {
super();
this.dispatch = d3dispatch.dispatch(
'updateNodes',
'updateSvg',
......@@ -154,16 +148,12 @@ export class SvgTree {
/**
* Initializes the tree component - created basic markup, loads and renders data
*
* @param {HTMLElement} selector
* @param {Object} settings
* @todo declare private
*/
public initialize(selector: HTMLElement, settings: any): void {
this.wrapper = selector;
public doSetup(settings: any): void {
Object.assign(this.settings, settings);
this.wrapper.append(...renderNodes(this.getTemplate()));
this.svg = d3selection.select(this.wrapper).select('svg');
this.svg = d3selection.select(this).select('svg');
this.container = this.svg.select('.nodes-wrapper') as TreeWrapperSelection<SVGGElement>;
this.nodesBgContainer = this.container.select('.nodes-bg') as TreeWrapperSelection<SVGGElement>;
this.linksContainer = this.container.select('.links') as TreeWrapperSelection<SVGGElement>;
......@@ -172,8 +162,7 @@ export class SvgTree {
this.updateScrollPosition();
this.loadData();
this.addEventListeners();
this.wrapper.dispatchEvent(new Event('svg-tree:initialized'));
this.dispatchEvent(new Event('svg-tree:initialized'));
}
/**
......@@ -204,7 +193,7 @@ export class SvgTree {
* Return the DOM element of a tree node
*/
public getNodeElement(node: TreeNode): HTMLElement|null {
return this.wrapper.querySelector('#identifier-' + this.getNodeStateIdentifier(node));
return this.querySelector('#identifier-' + this.getNodeStateIdentifier(node));
}
/**
......@@ -289,11 +278,11 @@ export class SvgTree {
}
public nodesRemovePlaceholder() {
const nodeLoader = this.wrapper.querySelector('.node-loader') as HTMLElement;
const nodeLoader = this.querySelector('.node-loader') as HTMLElement;
if (nodeLoader) {
nodeLoader.style.display = 'none';
}
const componentWrapper = this.wrapper.closest('.svg-tree');
const componentWrapper = this.closest('.svg-tree');
const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement;
if (treeLoader) {
treeLoader.style.display = 'none';
......@@ -302,13 +291,13 @@ export class SvgTree {
public nodesAddPlaceholder(node: TreeNode = null) {
if (node) {
const nodeLoader = this.wrapper.querySelector('.node-loader') as HTMLElement;
const nodeLoader = this.querySelector('.node-loader') as HTMLElement;
if (nodeLoader) {
nodeLoader.style.top = '' + (node.y + this.settings.marginTop);
nodeLoader.style.display = 'block';
}
} else {
const componentWrapper = this.wrapper.closest('.svg-tree');
const componentWrapper = this.closest('.svg-tree');
const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement;
if (treeLoader) {
treeLoader.style.display = 'block';
......@@ -424,7 +413,6 @@ export class SvgTree {
return;
}
this.icons = this.icons || {};
if (!(iconName in this.icons)) {
this.icons[iconName] = {
identifier: iconName,
......@@ -450,7 +438,7 @@ export class SvgTree {
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);
const focusableElement = this.wrapper.querySelector('[tabindex="0"]');
const focusableElement = this.querySelector('[tabindex="0"]');
const checkedNodeInViewport = visibleNodes.find((node: TreeNode) => node.checked);
let nodes = this.nodesContainer.selectAll('.node')
.data(visibleNodes, (node: TreeNode) => node.stateIdentifier);
......@@ -647,23 +635,33 @@ export class SvgTree {
}
}
/**
* Create element:
*
* <svg version="1.1" width="100%">
* <g class="nodes-wrapper">
* <g class="nodes-bg"><rect class="node-bg"></rect></g>
* <g class="links"><path class="link"></path></g>
* <g class="nodes"><g class="node"></g></g>
* </g>
* </svg>
*/
protected getTemplate(): TemplateResult {
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener('resize', () => this.updateView());
this.addEventListener('scroll', () => this.updateView());
this.addEventListener('svg-tree:visible', () => this.updateView());
window.addEventListener('resize', () => {
if (this.getClientRects().length > 0) {
this.updateView();
}
});
}
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
protected render(): TemplateResult {
return html`
<div class="node-loader">
<typo3-backend-icon identifier="spinner-circle-light" size="small"></typo3-backend-icon>
</div>
<svg version="1.1" width="100%">
<svg version="1.1"
width="100%"
@mouseover=${() => this.isOverSvg = true}
@mouseout=${() => this.isOverSvg = false}
@keydown=${(evt: KeyboardEvent) => this.handleKeyboardInteraction(evt)}>
<g class="nodes-wrapper" transform="translate(${this.settings.indentWidth / 2},${this.settings.nodeHeight / 2})">
<g class="nodes-bg"></g>
<g class="links"></g>
......@@ -674,22 +672,16 @@ export class SvgTree {
`;
}
/**
* Add an event listener Update svg tree after changed window height
*/
protected addEventListeners() {
this.wrapper.addEventListener('resize', () => this.updateView());
this.wrapper.addEventListener('scroll', () => this.updateView());
this.wrapper.addEventListener('svg-tree:visible', () => this.updateView());
window.addEventListener('resize', () => {
if (this.wrapper.getClientRects().length > 0) {
this.updateView();
}
});
const svgElement = this.wrapper.querySelector('svg') as SVGElement;
svgElement.addEventListener('mouseover', () => this.isOverSvg = true)
svgElement.addEventListener('mouseout', () => this.isOverSvg = false)
svgElement.addEventListener('keydown', (evt: KeyboardEvent) => this.handleKeyboardInteraction(evt));
protected firstUpdated(): void {
this.svg = d3selection.select(this.querySelector('svg'))
this.container = d3selection.select(this.querySelector('.nodes-wrapper'))
.attr('transform', 'translate(' + (this.settings.indentWidth / 2) + ',' + (this.settings.nodeHeight / 2) + ')') as any;
this.nodesBgContainer = d3selection.select(this.querySelector('.nodes-bg')) as any;
this.linksContainer = d3selection.select(this.querySelector('.links')) as any;
this.nodesContainer = d3selection.select(this.querySelector('.nodes')) as any;
this.doSetup(this.setup || {});
this.updateView();
}
protected updateView(): void {
......@@ -928,11 +920,10 @@ export class SvgTree {
* Updates variables used for visible nodes calculation
*/
private updateScrollPosition(): void {
this.viewportHeight = this.wrapper.getBoundingClientRect().height;
this.scrollTop = this.wrapper.scrollTop;
this.viewportHeight = this.getBoundingClientRect().height;
this.scrollBottom = this.scrollTop + this.viewportHeight + (this.viewportHeight / 2);
// disable tooltips when scrolling
Tooltip.hide(this.wrapper.querySelectorAll('[data-bs-toggle=tooltip]'));