Commit e4dcc1e0 authored by Benni Mack's avatar Benni Mack
Browse files

[TASK] Use Lit-based template for SVG tree wrapper

The SVG tree is now building the SVG elements
and needed containers ("<g>")  via lit-helper.

In addition, the update() method is renamed to
this.updateVisibleNodes() to easier integrate
this as Lit element, as update() is a reserved protected
method in lit.

The initialize() method is much cleaner, as the event listener
registration is now separated in a custom method.

Resolves: #93724
Releases: master
Change-Id: I945cd620f9900c6ea535dfe5f0d44ee5ced46f89
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68367

Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: default avatarDaniel Gorges <daniel.gorges@b13.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: default avatarDaniel Gorges <daniel.gorges@b13.com>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent 04001c39
...@@ -56,6 +56,24 @@ export class SelectTree extends SvgTree ...@@ -56,6 +56,24 @@ export class SelectTree extends SvgTree
this.dispatch.on('prepareLoadedNode.selectTree', (node: TreeNode) => this.prepareLoadedNode(node)); this.dispatch.on('prepareLoadedNode.selectTree', (node: TreeNode) => this.prepareLoadedNode(node));
} }
/**
* Expand all nodes and refresh view
*/
public expandAll(): void {
this.nodes.forEach((node: TreeNode) => { this.showChildren(node); });
this.prepareDataForVisibleNodes();
this.updateVisibleNodes();
}
/**
* Collapse all nodes recursively and refresh view
*/
public collapseAll(): void {
this.nodes.forEach((node: TreeNode) => { this.hideChildren(node); });
this.prepareDataForVisibleNodes();
this.updateVisibleNodes();
}
/** /**
* Node selection logic (triggered by different events) * Node selection logic (triggered by different events)
*/ */
...@@ -76,30 +94,7 @@ export class SelectTree extends SvgTree ...@@ -76,30 +94,7 @@ export class SelectTree extends SvgTree
node.checked = !checked; node.checked = !checked;
this.dispatch.call('nodeSelectedAfter', this, node); this.dispatch.call('nodeSelectedAfter', this, node);
this.update(); this.updateVisibleNodes();
}
/**
* Function relays on node.indeterminate state being up to date
*
* @param {Selection} nodes
*/
public updateNodes(nodes: TreeNodeSelection): void {
nodes
.selectAll('.tree-check use')
.attr('visibility', function(this: SVGUseElement, node: TreeNode): string {
const checked = Boolean(node.checked);
const selection = d3selection.select(this);
if (selection.classed('icon-checked') && checked) {
return 'visible';
} else if (selection.classed('icon-indeterminate') && node.indeterminate && !checked) {
return 'visible';
} else if (selection.classed('icon-check') && !node.indeterminate && !checked) {
return 'visible';
} else {
return 'hidden';
}
});
} }
public filter(searchTerm?: string|null): void { public filter(searchTerm?: string|null): void {
...@@ -121,7 +116,7 @@ export class SelectTree extends SvgTree ...@@ -121,7 +116,7 @@ export class SelectTree extends SvgTree
}); });
this.prepareDataForVisibleNodes(); this.prepareDataForVisibleNodes();
this.update(); this.updateVisibleNodes();
} }
/** /**
...@@ -147,6 +142,27 @@ export class SelectTree extends SvgTree ...@@ -147,6 +142,27 @@ export class SelectTree extends SvgTree
return !this.settings.readOnlyMode && this.settings.unselectableElements.indexOf(node.identifier) === -1; return !this.settings.readOnlyMode && this.settings.unselectableElements.indexOf(node.identifier) === -1;
} }
/**
* Function relays on node.indeterminate state being up to date
*/
private updateNodes(nodes: TreeNodeSelection): void {
nodes
.selectAll('.tree-check use')
.attr('visibility', function(this: SVGUseElement, node: TreeNode): string {
const checked = Boolean(node.checked);
const selection = d3selection.select(this);
if (selection.classed('icon-checked') && checked) {
return 'visible';
} else if (selection.classed('icon-indeterminate') && node.indeterminate && !checked) {
return 'visible';
} else if (selection.classed('icon-check') && !node.indeterminate && !checked) {
return 'visible';
} else {
return 'hidden';
}
});
}
/** /**
* Check if a node has all information to be used. * Check if a node has all information to be used.
*/ */
......
...@@ -171,6 +171,6 @@ class TreeToolbar extends LitElement { ...@@ -171,6 +171,6 @@ class TreeToolbar extends LitElement {
this.tree.nodes.forEach((node: any) => node.hidden = false); this.tree.nodes.forEach((node: any) => node.hidden = false);
} }
this.tree.prepareDataForVisibleNodes(); this.tree.prepareDataForVisibleNodes();
this.tree.update(); this.tree.updateVisibleNodes();
} }
} }
...@@ -104,7 +104,7 @@ export class PageTree extends SvgTree ...@@ -104,7 +104,7 @@ export class PageTree extends SvgTree
if (response && response.hasErrors) { if (response && response.hasErrors) {
this.errorNotification(response.messages, false); this.errorNotification(response.messages, false);
this.nodesContainer.selectAll('.node').remove(); this.nodesContainer.selectAll('.node').remove();
this.update(); this.updateVisibleNodes();
this.nodesRemovePlaceholder(); this.nodesRemovePlaceholder();
} else { } else {
this.refreshOrFilterTree(); this.refreshOrFilterTree();
...@@ -179,7 +179,7 @@ export class PageTree extends SvgTree ...@@ -179,7 +179,7 @@ export class PageTree extends SvgTree
this.disableSelectedNodes(); this.disableSelectedNodes();
node.checked = true; node.checked = true;
this.dispatch.call('nodeSelectedAfter', this, node); this.dispatch.call('nodeSelectedAfter', this, node);
this.update(); this.updateVisibleNodes();
} }
/** /**
...@@ -239,7 +239,7 @@ export class PageTree extends SvgTree ...@@ -239,7 +239,7 @@ export class PageTree extends SvgTree
parentNode.loaded = true; parentNode.loaded = true;
this.setParametersNode(); this.setParametersNode();
this.prepareDataForVisibleNodes(); this.prepareDataForVisibleNodes();
this.update(); this.updateVisibleNodes();
this.nodesRemovePlaceholder(); this.nodesRemovePlaceholder();
this.switchFocusNode(parentNode); this.switchFocusNode(parentNode);
......
...@@ -238,7 +238,7 @@ export class ToolbarDragHandler implements DragDropHandler { ...@@ -238,7 +238,7 @@ export class ToolbarDragHandler implements DragDropHandler {
this.tree.nodes.splice(index, 0, newNode); this.tree.nodes.splice(index, 0, newNode);
this.tree.setParametersNode(); this.tree.setParametersNode();
this.tree.prepareDataForVisibleNodes(); this.tree.prepareDataForVisibleNodes();
this.tree.update(); this.tree.updateVisibleNodes();
this.tree.removeEditedText(); this.tree.removeEditedText();
d3selection.select(this.tree.svg.node().parentNode as HTMLElement) d3selection.select(this.tree.svg.node().parentNode as HTMLElement)
...@@ -295,7 +295,7 @@ export class ToolbarDragHandler implements DragDropHandler { ...@@ -295,7 +295,7 @@ export class ToolbarDragHandler implements DragDropHandler {
this.tree.nodes.splice(index, 1); this.tree.nodes.splice(index, 1);
this.tree.setParametersNode(); this.tree.setParametersNode();
this.tree.prepareDataForVisibleNodes(); this.tree.prepareDataForVisibleNodes();
this.tree.update(); this.tree.updateVisibleNodes();
this.tree.removeEditedText(); this.tree.removeEditedText();
}; };
} }
......
...@@ -77,11 +77,7 @@ export class PageTreeNavigationComponent extends LitElement { ...@@ -77,11 +77,7 @@ export class PageTreeNavigationComponent extends LitElement {
</div> </div>
<div id="typo3-pagetree-treeContainer" class="navigation-tree-container"> <div id="typo3-pagetree-treeContainer" class="navigation-tree-container">
${this.renderMountPoint()} ${this.renderMountPoint()}
<div id="typo3-pagetree-tree" class="svg-tree-wrapper"> <div id="typo3-pagetree-tree" class="svg-tree-wrapper"></div>
<div class="node-loader">
<typo3-backend-icon identifier="spinner-circle-light" size="small"></typo3-backend-icon>
</div>
</div>
</div> </div>
</div> </div>
<div class="svg-tree-loader"> <div class="svg-tree-loader">
...@@ -162,7 +158,7 @@ export class PageTreeNavigationComponent extends LitElement { ...@@ -162,7 +158,7 @@ export class PageTreeNavigationComponent extends LitElement {
.then((response) => { .then((response) => {
if (response && response.hasErrors) { if (response && response.hasErrors) {
this.tree.errorNotification(response.message, true); this.tree.errorNotification(response.message, true);
this.tree.update(); this.tree.updateVisibleNodes();
} else { } else {
this.mountPointPath = response.mountPointPath; this.mountPointPath = response.mountPointPath;
this.tree.refreshOrFilterTree(); this.tree.refreshOrFilterTree();
......
...@@ -11,6 +11,8 @@ ...@@ -11,6 +11,8 @@
* The TYPO3 project - inspiring people to share! * The TYPO3 project - inspiring people to share!
*/ */
import {html, TemplateResult} from 'lit-element';
import {renderNodes} from 'TYPO3/CMS/Core/lit-helper';
import {TreeNode} from './Tree/TreeNode'; import {TreeNode} from './Tree/TreeNode';
import * as d3selection from 'd3-selection'; import * as d3selection from 'd3-selection';
import * as d3dispatch from 'd3-dispatch'; import * as d3dispatch from 'd3-dispatch';
...@@ -96,6 +98,7 @@ export class SvgTree { ...@@ -96,6 +98,7 @@ export class SvgTree {
width: 300, width: 300,
duration: 400, duration: 400,
dataUrl: '', dataUrl: '',
filterUrl: '',
defaultProperties: {}, defaultProperties: {},
expandUpToLevel: null as any, expandUpToLevel: null as any,
}; };
...@@ -117,8 +120,8 @@ export class SvgTree { ...@@ -117,8 +120,8 @@ export class SvgTree {
protected linksContainer: TreeWrapperSelection<SVGGElement> = null; protected linksContainer: TreeWrapperSelection<SVGGElement> = null;
protected data: SvgTreeData = new class implements SvgTreeData { protected data: SvgTreeData = new class implements SvgTreeData {
links: SvgTreeDataLink[]; links: SvgTreeDataLink[] = [];
nodes: TreeNode[]; nodes: TreeNode[] = [];
}; };
/** /**
...@@ -156,73 +159,21 @@ export class SvgTree { ...@@ -156,73 +159,21 @@ export class SvgTree {
* @param {Object} settings * @param {Object} settings
*/ */
public initialize(selector: HTMLElement, settings: any): void { public initialize(selector: HTMLElement, settings: any): void {
Object.assign(this.settings, settings);
this.wrapper = selector; this.wrapper = selector;
/** Object.assign(this.settings, settings);
* 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>
*/
this.svg = d3selection.select(this.wrapper).append('svg')
.attr('version', '1.1')
.attr('width', '100%')
.on('mouseover', () => this.isOverSvg = true)
.on('mouseout', () => this.isOverSvg = false)
.on('keydown', (evt: KeyboardEvent) => this.handleKeyboardInteraction(evt));
this.container = this.svg this.wrapper.append(...renderNodes(this.getTemplate()));
.append('g') this.svg = d3selection.select(this.wrapper).select('svg');
.attr('class', 'nodes-wrapper') this.container = this.svg.select('.nodes-wrapper') as TreeWrapperSelection<SVGGElement>;
.attr('transform', 'translate(' + (this.settings.indentWidth / 2) + ',' + (this.settings.nodeHeight / 2) + ')'); this.nodesBgContainer = this.container.select('.nodes-bg') as TreeWrapperSelection<SVGGElement>;
this.nodesBgContainer = this.container.append('g') this.linksContainer = this.container.select('.links') as TreeWrapperSelection<SVGGElement>;
.attr('class', 'nodes-bg'); this.nodesContainer = this.container.select('.nodes') as TreeWrapperSelection<SVGGElement>;
this.linksContainer = this.container.append('g') this.iconsContainer = this.svg.select('defs') as TreeWrapperSelection<SVGGElement>;
.attr('class', 'links');
this.nodesContainer = this.container.append('g')
.attr('class', 'nodes')
.attr('role', 'tree');
if (this.settings.showIcons) {
this.iconsContainer = this.svg.append('defs');
}
this.updateScrollPosition(); this.updateScrollPosition();
this.loadData(); this.loadData();
this.addEventListeners();
this.wrapper.addEventListener('resize', () => {
this.updateScrollPosition();
this.update();
});
this.wrapper.addEventListener('scroll', () => {
this.updateScrollPosition();
this.update();
});
this.wrapper.addEventListener('svg-tree:visible', () => {
this.updateScrollPosition();
this.update();
});
this.wrapper.dispatchEvent(new Event('svg-tree:initialized')); this.wrapper.dispatchEvent(new Event('svg-tree:initialized'));
this.resize();
}
/**
* Update svg tree after changed window height
*/
public resize() {
window.addEventListener('resize', () => {
if (this.wrapper.getClientRects().length > 0) {
this.updateScrollPosition();
this.update();
}
});
} }
/** /**
...@@ -245,7 +196,7 @@ export class SvgTree { ...@@ -245,7 +196,7 @@ export class SvgTree {
/** /**
* Make the DOM element of the node given as parameter focusable and focus it * Make the DOM element of the node given as parameter focusable and focus it
*/ */
public switchFocusNode(node: TreeNode) { public switchFocusNode(node: TreeNode): void {
this.switchFocus(this.getNodeElement(node)); this.switchFocus(this.getNodeElement(node));
} }
...@@ -253,18 +204,7 @@ export class SvgTree { ...@@ -253,18 +204,7 @@ export class SvgTree {
* Return the DOM element of a tree node * Return the DOM element of a tree node
*/ */
public getNodeElement(node: TreeNode): HTMLElement|null { public getNodeElement(node: TreeNode): HTMLElement|null {
return document.getElementById('identifier-' + this.getNodeStateIdentifier(node)); return this.wrapper.querySelector('#identifier-' + this.getNodeStateIdentifier(node));
}
/**
* Updates variables used for visible nodes calculation
*/
public updateScrollPosition() {
this.viewportHeight = this.wrapper.getBoundingClientRect().height;
this.scrollTop = this.wrapper.scrollTop;
this.scrollBottom = this.scrollTop + this.viewportHeight + (this.viewportHeight / 2);
// disable tooltips when scrolling
Tooltip.hide(this.wrapper.querySelectorAll('[data-bs-toggle=tooltip]'));
} }
/** /**
...@@ -272,7 +212,6 @@ export class SvgTree { ...@@ -272,7 +212,6 @@ export class SvgTree {
*/ */
public loadData() { public loadData() {
this.nodesAddPlaceholder(); this.nodesAddPlaceholder();
(new AjaxRequest(this.settings.dataUrl)) (new AjaxRequest(this.settings.dataUrl))
.get({cache: 'no-cache'}) .get({cache: 'no-cache'})
.then((response: AjaxResponse) => response.resolve()) .then((response: AjaxResponse) => response.resolve())
...@@ -282,7 +221,7 @@ export class SvgTree { ...@@ -282,7 +221,7 @@ export class SvgTree {
this.nodesRemovePlaceholder(); this.nodesRemovePlaceholder();
// @todo: needed? // @todo: needed?
this.updateScrollPosition(); this.updateScrollPosition();
this.update(); this.updateVisibleNodes();
}) })
.catch((error) => { .catch((error) => {
this.errorNotification(error, false); this.errorNotification(error, false);
...@@ -301,7 +240,7 @@ export class SvgTree { ...@@ -301,7 +240,7 @@ export class SvgTree {
this.nodesContainer.selectAll('.node').remove(); this.nodesContainer.selectAll('.node').remove();
this.nodesBgContainer.selectAll('.node-bg').remove(); this.nodesBgContainer.selectAll('.node-bg').remove();
this.linksContainer.selectAll('.link').remove(); this.linksContainer.selectAll('.link').remove();
this.update(); this.updateVisibleNodes();
} }
/** /**
...@@ -350,11 +289,11 @@ export class SvgTree { ...@@ -350,11 +289,11 @@ export class SvgTree {
} }
public nodesRemovePlaceholder() { public nodesRemovePlaceholder() {
const componentWrapper = this.svg.node().closest('.svg-tree'); const nodeLoader = this.wrapper.querySelector('.node-loader') as HTMLElement;
const nodeLoader = componentWrapper?.querySelector('.node-loader') as HTMLElement;
if (nodeLoader) { if (nodeLoader) {
nodeLoader.style.display = 'none'; nodeLoader.style.display = 'none';
} }
const componentWrapper = this.wrapper.closest('.svg-tree');
const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement; const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement;
if (treeLoader) { if (treeLoader) {
treeLoader.style.display = 'none'; treeLoader.style.display = 'none';
...@@ -362,14 +301,14 @@ export class SvgTree { ...@@ -362,14 +301,14 @@ export class SvgTree {
} }
public nodesAddPlaceholder(node: TreeNode = null) { public nodesAddPlaceholder(node: TreeNode = null) {
const componentWrapper = this.svg.node().closest('.svg-tree');
if (node) { if (node) {
const nodeLoader = componentWrapper?.querySelector('.node-loader') as HTMLElement; const nodeLoader = this.wrapper.querySelector('.node-loader') as HTMLElement;
if (nodeLoader) { if (nodeLoader) {
nodeLoader.style.top = '' + (node.y + this.settings.marginTop); nodeLoader.style.top = '' + (node.y + this.settings.marginTop);
nodeLoader.style.display = 'block'; nodeLoader.style.display = 'block';
} }
} else { } else {
const componentWrapper = this.wrapper.closest('.svg-tree');
const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement; const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement;
if (treeLoader) { if (treeLoader) {
treeLoader.style.display = 'block'; treeLoader.style.display = 'block';
...@@ -430,30 +369,12 @@ export class SvgTree { ...@@ -430,30 +369,12 @@ export class SvgTree {
} }
} }
/**
* Expand all nodes and refresh view
*/
public expandAll(): void {
this.nodes.forEach(this.showChildren.bind(this));
this.prepareDataForVisibleNodes();
this.update();
}
/**
* Collapse all nodes recursively and refresh view
*/
public collapseAll(): void {
this.nodes.forEach(this.hideChildren.bind(this));
this.prepareDataForVisibleNodes();
this.update();
}
/** /**
* Filters out invisible nodes (collapsed) from the full dataset (this.rootNode) * Filters out invisible nodes (collapsed) from the full dataset (this.rootNode)
* and enriches dataset with additional properties * and enriches dataset with additional properties
* Visible dataset is stored in this.data * Visible dataset is stored in this.data
*/ */
public prepareDataForVisibleNodes() { public prepareDataForVisibleNodes(): void {
const blacklist: {[keys: string]: boolean} = {}; const blacklist: {[keys: string]: boolean} = {};
this.nodes.forEach((node: TreeNode, index: number): void => { this.nodes.forEach((node: TreeNode, index: number): void => {
if (!node.expanded) { if (!node.expanded) {
...@@ -471,7 +392,6 @@ export class SvgTree { ...@@ -471,7 +392,6 @@ export class SvgTree {
this.data.nodes.forEach((node: TreeNode, i: number) => { this.data.nodes.forEach((node: TreeNode, i: number) => {
// delete n.children; // delete n.children;
node.x = node.depth * this.settings.indentWidth; node.x = node.depth * this.settings.indentWidth;
if (node.readableRootline) { if (node.readableRootline) {
pathAboveMounts += this.settings.nodeHeight; pathAboveMounts += this.settings.nodeHeight;
} }
...@@ -516,7 +436,7 @@ export class SvgTree { ...@@ -516,7 +436,7 @@ export class SvgTree {
this.icons[iconName].icon = result[0]; this.icons[iconName].icon = result[0];
} }
if (update) { if (update) {
this.update(); this.updateVisibleNodes();
} }
}); });
} }
...@@ -525,7 +445,7 @@ export class SvgTree { ...@@ -525,7 +445,7 @@ export class SvgTree {
/** /**
* Renders the subset of the tree nodes fitting the viewport (adding, modifying and removing SVG nodes) * Renders the subset of the tree nodes fitting the viewport (adding, modifying and removing SVG nodes)
*/ */
public update() { public updateVisibleNodes(): void {
const visibleRows = Math.ceil(this.viewportHeight / this.settings.nodeHeight + 1); 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);