Commit b0b36436 authored by Benni Mack's avatar Benni Mack Committed by Benjamin Franzke

[FEATURE] Use SVG Trees in Record Selector

This change adds the SVG-based page tree in the
record selector and the Page Link picker, replacing
decade-old clumsy HTML code for generating trees.

This replacement not only unifies the user experience
across the navigation trees and the Element
Browser / Link Pickers making the whole look + feel
very consistent.

It also allows to finally use all the features built
in the SVG trees:

* A filter across the tree built on the browser and automatic
  refreshing
* collapse + expand and post-loading is now built with the
  same AJAX endpoints as the navigation components, making
  the general loading much faster
* Temporary Mount Points now look exactly the same as in the
  navigation area.
* Keyboard navigation
* The Content Area inside the Record Selector and the Link
  Pickers is replaced via AJAX calls

Especially the last feature makes it a breeze to use
the new trees to select items much faster.

All existing features, such as directly selecting a page
or link to a page within the tree is added as so-called
SVG-tree "actions" in the right corner when hovering,
avoiding the additional "play" icon in the tree.

Custom support for Element Browser "mount points" is also
kept and included.

The SVG tree is added to the following components:

* Database Browser / Record Selector (e.g. choosing storagePid in plugins)
* File Browser / Record Selector (e.g. selecting an image in the plugin)
* Folder Browser / Record Selector
* Link Picker for Pages / Content Elements ("link to a page")
* Link Picker for Files ("link to a file")
* Link Picker for Folders ("link to a folder")
* Link Picker for Records (e.g. news record)

All link picker functionality is available for typolink fields (e.g. tt_content.header_link)
and when linking inside the Rich Text Editor.

Some technical details under-the-hood:
* The trees are now built as SVG tree / LIT elements
* All Element Browser / Link Picker trees are named "Browsable...Tree"
* The AJAX endpoints for fetching data are used
* The Page Tree now uses a different configuration URL with a "?readonly=1" parameter, with the main difference to initialize EB mountpoints instead
* Within the tree: The SVG tree actions is a separate container for elements on the right-hand of the tree
* The GET parameter "contentOnly" represents to only load parts of the HTML (the content area) when fetching from AJAX

Resolves: #73176
Resolves: #92430
Releases: master
Change-Id: I5ef9534076bd6fa297b51c0ed9e90af91035be80
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67687Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Tested-by: Jonas Eberle's avatarJonas Eberle <flightvision@googlemail.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Tested-by: Daniel Goerz's avatarDaniel Goerz <daniel.goerz@posteo.de>
Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
parent 356300b5
......@@ -48,10 +48,6 @@ $elementbrowser-breakpoint: 600px;
flex-flow: nowrap column;
width: 100%;
@media (min-width: $elementbrowser-breakpoint) {
height: 100vh;
}
h3 {
font-size: 1.2em;
}
......@@ -59,7 +55,6 @@ $elementbrowser-breakpoint: 600px;
.element-browser-body {
overflow: auto;
height: 100%;
padding: ($grid-gutter-width / 2);
> *:first-child {
......@@ -75,6 +70,9 @@ $elementbrowser-breakpoint: 600px;
padding: ($grid-gutter-width / 2);
color: #fff;
background-color: #292929;
position: sticky;
top: 0;
z-index: 2;
a {
color: inherit;
......@@ -83,10 +81,18 @@ $elementbrowser-breakpoint: 600px;
}
.element-browser-tabs {
position: sticky;
top: 0;
z-index: 2;
.nav-tabs {
padding: ($grid-gutter-width / 2);
padding-bottom: 0;
}
.link-browser-has-title & {
top: 42px;
}
}
.element-browser-attributes {
......@@ -109,6 +115,19 @@ $elementbrowser-breakpoint: 600px;
.element-browser-main-sidebar {
background-color: #f2f2f2;
position: sticky;
top: 0;
height: 100vh;
.link-browser & {
top: 44px;
height: calc(100vh - 44px);
}
.link-browser.link-browser-has-title & {
top: 86px;
height: calc(100vh - 86px);
}
@media (min-width: $elementbrowser-breakpoint) {
flex-shrink: 0;
......@@ -147,6 +166,30 @@ $elementbrowser-breakpoint: 600px;
}
}
.link-browser .scaffold-content-navigation-available {
.scaffold-content-navigation-drag,
.scaffold-content-navigation-switcher {
position: sticky;
top: 44px;
}
.scaffold-content-navigation-drag {
height: calc(100vh - 44px);
}
}
.link-browser.link-browser-has-title .scaffold-content-navigation-available {
.scaffold-content-navigation-drag,
.scaffold-content-navigation-switcher {
position: sticky;
top: 86px;
}
.scaffold-content-navigation-drag {
height: calc(100vh - 86px);
}
}
.element-browser-main-content {
@media (min-width: $elementbrowser-breakpoint) {
overflow: auto;
......
......@@ -34,6 +34,11 @@ $svgColors: (
z-index: 3000;
user-select: none;
.element-browser & {
height: calc(100% - 39px);
top: 39px;
}
& > * {
position: absolute;
top: 0;
......@@ -302,8 +307,11 @@ $svgColors: (
left: 0;
}
.svg-toolbar {
.scaffold-content .svg-toolbar {
min-height: $module-docheader-height;
}
.svg-toolbar {
padding: 4px 10px 0;
border-bottom: 1px solid $module-docheader-border;
background-color: $module-docheader-bg;
......@@ -323,10 +331,6 @@ $svgColors: (
}
}
&__submenu {
margin: 0.125rem 0;
}
&__drag-node {
display: inline-block;
cursor: move;
......@@ -362,3 +366,20 @@ $svgColors: (
overflow: hidden;
}
}
.node-action {
opacity: 0;
cursor: pointer;
path {
opacity: 0;
}
&.node-action-over {
opacity: 1;
path {
opacity: 1;
}
}
}
......@@ -329,7 +329,7 @@ body {
.scaffold-content-navigation-switcher-btn {
display: inline-block;
padding: 3px 6px;
padding: 0.25rem 0.325rem;
margin-top: 0.125rem;
}
......
......@@ -288,11 +288,6 @@ export class PageTreeNavigationComponent extends LitElement {
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
});
this.configuration = configuration;
this.mountPointPath = configuration.temporaryMountPoint || null;
return configuration;
......@@ -368,7 +363,7 @@ export class PageTreeNavigationComponent extends LitElement {
}
private setTemporaryMountPoint(pid: number): void {
(new AjaxRequest(top.TYPO3.settings.ajaxUrls.page_tree_set_temporary_mount_point))
(new AjaxRequest(this.configuration.setTemporaryMountPointUrl))
.post('pid=' + pid, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'},
})
......
......@@ -66,6 +66,7 @@ export class SvgTree extends LitElement {
filterUrl: '',
defaultProperties: {},
expandUpToLevel: null as any,
actions: []
};
/**
......@@ -103,6 +104,7 @@ export class SvgTree extends LitElement {
public textPosition: number = 10;
protected icons: {[keys: string]: SvgTreeDataIcon} = {};
protected nodesActionsContainer: TreeWrapperSelection<SVGGElement> = null;
/**
* SVG <defs> container wrapping all icon definitions
......@@ -142,6 +144,7 @@ export class SvgTree extends LitElement {
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.nodesActionsContainer = this.container.select('.nodes-actions') as TreeWrapperSelection<SVGGElement>;
this.linksContainer = this.container.select('.links') as TreeWrapperSelection<SVGGElement>;
this.nodesContainer = this.container.select('.nodes') as TreeWrapperSelection<SVGGElement>;
this.iconsContainer = this.svg.select('defs') as TreeWrapperSelection<SVGGElement>;
......@@ -213,6 +216,7 @@ export class SvgTree extends LitElement {
this.prepareDataForVisibleNodes();
this.nodesContainer.selectAll('.node').remove();
this.nodesBgContainer.selectAll('.node-bg').remove();
this.nodesActionsContainer.selectAll('.node-action').remove();
this.linksContainer.selectAll('.link').remove();
this.updateVisibleNodes();
}
......@@ -425,16 +429,22 @@ export class SvgTree extends LitElement {
const visibleNodes = this.data.nodes.slice(position, position + visibleRows);
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);
const nodesBg = this.nodesBgContainer.selectAll('.node-bg')
.data(visibleNodes, (node: TreeNode) => node.stateIdentifier);
const nodesActions = this.nodesActionsContainer.selectAll('.node-action')
.data(visibleNodes, (node: TreeNode) => node.stateIdentifier);
// delete nodes without corresponding data
nodes.exit().remove();
// delete
nodesBg.exit().remove();
nodesActions.exit().remove();
// update nodes actions
this.updateNodeActions(nodesActions);
// update nodes background
const nodeBgClass = this.updateNodeBgClass(nodesBg);
......@@ -660,6 +670,7 @@ export class SvgTree extends LitElement {
<g class="nodes-bg"></g>
<g class="links"></g>
<g class="nodes" role="tree"></g>
<g class="nodes-actions"></g>
</g>
<defs></defs>
</svg>
......@@ -671,6 +682,7 @@ export class SvgTree extends LitElement {
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.nodesActionsContainer = d3selection.select(this.querySelector('.nodes-actions')) as any;
this.linksContainer = d3selection.select(this.querySelector('.links')) as any;
this.nodesContainer = d3selection.select(this.querySelector('.nodes')) as any;
......@@ -681,6 +693,9 @@ export class SvgTree extends LitElement {
protected updateView(): void {
this.updateScrollPosition();
this.updateVisibleNodes();
if (this.settings.actions && this.settings.actions.length) {
this.nodesActionsContainer.attr('transform', 'translate(' + (this.querySelector('svg').clientWidth - 16 - ((16 * this.settings.actions.length))) + ',0)');
}
}
protected disableSelectedNodes(): void {
......@@ -692,6 +707,23 @@ export class SvgTree extends LitElement {
});
}
/**
* Ensure to update the actions column to stick to the very end
*/
protected updateNodeActions(nodesActions: TreeNodeSelection): TreeNodeSelection {
if (this.settings.actions && this.settings.actions.length) {
return nodesActions.enter()
.append('g')
.merge(nodesActions as d3selection.Selection<SVGGElement, TreeNode, any, any>)
.attr('class', 'node-action')
.on('mouseover', (evt: MouseEvent, node: TreeNode) => this.onMouseOverNode(node))
.on('mouseout', (evt: MouseEvent, node: TreeNode) => this.onMouseOutOfNode(node))
.attr('data-state-id', this.getNodeStateIdentifier)
.attr('transform', this.getNodeActionTransform)
}
return nodesActions.enter();
}
/**
* Check whether node can be selected.
* In some cases (e.g. selecting a parent) it should not be possible to select
......@@ -830,7 +862,7 @@ export class SvgTree extends LitElement {
return node.expanded ? 'translate(16,0) rotate(90)' : ' rotate(0)';
}
protected getChevronColor(node: TreeNode): string {
protected getChevronColor(node: TreeNode): string {
return node.expanded ? '#000' : '#8e8e8e';
}
......@@ -882,6 +914,15 @@ export class SvgTree extends LitElement {
return 'translate(-8, ' + ((node.y || 0) - 10) + ')';
}
/**
* Returns a 'transform' attribute value for the node background element (absolute positioning)
*
* @param {Node} node
*/
protected getNodeActionTransform(node: TreeNode): string {
return 'translate(0, ' + ((node.y || 0) - 9) + ')';
}
/**
* Event handler for clicking on a node's icon
*/
......@@ -1018,6 +1059,13 @@ export class SvgTree extends LitElement {
.attr('rx', '3')
.attr('ry', '3');
}
let elementNodeAction = this.nodesActionsContainer.select('.node-action[data-state-id="' + node.stateIdentifier + '"]');
if (elementNodeAction.size()) {
elementNodeAction.classed('node-action-over', true);
// @todo: needs to be adapted for active nodes
elementNodeAction.attr('fill', elementNodeBg.style('fill'));
}
}
/**
* node background events
......@@ -1033,6 +1081,12 @@ export class SvgTree extends LitElement {
.attr('rx', '0')
.attr('ry', '0');
}
let elementNodeAction = this.nodesActionsContainer.select('.node-action[data-state-id="' + node.stateIdentifier + '"]');
if (elementNodeAction.size()) {
elementNodeAction.classed('node-action-over', false);
}
}
/**
......
/*
* 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 {customElement, html, LitElement, query, TemplateResult} from 'lit-element';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {TreeNode} from './TreeNode';
import {Toolbar, TreeNodeSelection} from '../SvgTree';
import ElementBrowser = require('TYPO3/CMS/Recordlist/ElementBrowser');
import LinkBrowser = require('TYPO3/CMS/Recordlist/LinkBrowser');
import 'TYPO3/CMS/Backend/Element/IconElement';
import Persistent from 'TYPO3/CMS/Backend/Storage/Persistent';
import {FileStorageTree} from './FileStorageTree';
const componentName: string = 'typo3-backend-component-filestorage-browser';
/**
* Extension of the SVG Tree, allowing to show additional actions on the right hand of the tree to directly link
* select a folder
*/
@customElement('typo3-backend-component-filestorage-browser-tree')
class FileStorageBrowserTree extends FileStorageTree {
protected updateNodeActions(nodesActions: TreeNodeSelection): TreeNodeSelection {
const nodes = super.updateNodeActions(nodesActions);
if (this.settings.actions.includes('link')) {
// Check if a node can be linked
this.fetchIcon('actions-link');
const linkAction = nodes
.append('g')
.on('click', (evt: MouseEvent, node: TreeNode) => {
this.linkItem(node);
});
linkAction
// improve usability by making the click area a 16px square
.append('path')
.attr('d', 'M 0 0 L 18 0 L 18 18 L 0 18 Z')
.exit();
linkAction
.append('use')
.attr('xlink:href', '#icon-actions-link');
} else if (this.settings.actions.includes('select')) {
// Check if a node can be selected
this.fetchIcon('actions-link');
const linkAction = nodes
.append('g')
.on('click', (evt: MouseEvent, node: TreeNode) => {
this.selectItem(node);
});
linkAction
// improve usability by making the click area a 16px square
.append('path')
.attr('d', 'M 0 0 L 18 0 L 18 18 L 0 18 Z')
.exit();
linkAction
.append('use')
.attr('xlink:href', '#icon-actions-link');
}
return nodes;
}
/**
* Link to a folder - Link Handler specific
*/
private linkItem(node: TreeNode): void {
LinkBrowser.finalizeFunction('t3://folder?storage=' + node.storage + '&identifier=' + node.pathIdentifier);
}
/**
* Element Browser specific
*/
private selectItem(node: TreeNode): void {
ElementBrowser.insertElement(
node.itemType,
node.identifier,
node.name,
node.identifier,
true
);
}
}
@customElement(componentName)
export class FileStorageBrowser extends LitElement {
@query('.svg-tree-wrapper') tree: FileStorageBrowserTree;
private activeFolder: String = '';
private actions: Array<string> = [];
public connectedCallback(): void {
super.connectedCallback();
document.addEventListener('typo3:navigation:resized', this.triggerRender);
}
public disconnectedCallback(): void {
document.removeEventListener('typo3:navigation:resized', this.triggerRender);
super.disconnectedCallback();
}
protected firstUpdated() {
this.activeFolder = this.getAttribute('active-folder') || '';
}
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
protected triggerRender = (): void => {
this.tree.dispatchEvent(new Event('svg-tree:visible'));
}
protected render(): TemplateResult {
if (this.hasAttribute('tree-actions') && this.getAttribute('tree-actions').length) {
this.actions = JSON.parse(this.getAttribute('tree-actions'));
}
const treeSetup = {
dataUrl: top.TYPO3.settings.ajaxUrls.filestorage_tree_data,
filterUrl: top.TYPO3.settings.ajaxUrls.filestorage_tree_filter,
showIcons: true,
actions: this.actions
};
const initialized = () => {
this.tree.dispatchEvent(new Event('svg-tree:visible'));
this.tree.addEventListener('typo3:svg-tree:expand-toggle', this.toggleExpandState);
this.tree.addEventListener('typo3:svg-tree:node-selected', this.loadFolderDetails);
this.tree.addEventListener('typo3:svg-tree:nodes-prepared', this.selectActiveNode);
// set up toolbar now with updated properties
const toolbar = this.querySelector('typo3-backend-tree-toolbar') as Toolbar;
toolbar.tree = this.tree;
}
return html`
<div class="svg-tree">
<div>
<typo3-backend-tree-toolbar .tree="${this.tree}" class="svg-toolbar"></typo3-backend-tree-toolbar>
<div class="navigation-tree-container">
<typo3-backend-component-filestorage-browser-tree class="svg-tree-wrapper" .setup=${treeSetup} @svg-tree:initialized=${initialized}></typo3-backend-component-page-browser-tree>
</div>
</div>
<div class="svg-tree-loader">
<typo3-backend-icon identifier="spinner-circle-light" size="large"></typo3-backend-icon>
</div>
</div>
`;
}
private selectActiveNode = (evt: CustomEvent): void => {
// Activate the current node
let nodes = evt.detail.nodes as Array<TreeNode>;
evt.detail.nodes = nodes.map((node: TreeNode) => {
if (decodeURIComponent(node.identifier) === this.activeFolder) {
node.checked = true;
}
return node;
});
}
private toggleExpandState = (evt: CustomEvent): void => {
const node = evt.detail.node as TreeNode;
if (node) {
Persistent.set('BackendComponents.States.FileStorageTree.stateHash.' + node.stateIdentifier, (node.expanded ? '1' : '0'));
}
}
/**
* If a page is clicked, the content area needs to be updated
*/
private loadFolderDetails = (evt: CustomEvent): void => {
const node = evt.detail.node as TreeNode;
if (!node.checked) {
return;
}
let contentsUrl = document.location.href + '&contentOnly=1&expandFolder=' + node.identifier;
(new AjaxRequest(contentsUrl)).get()
.then((response: AjaxResponse) => response.resolve())
.then((response) => {
const contentContainer = document.querySelector('.element-browser-main-content .element-browser-body') as HTMLElement;
contentContainer.innerHTML = response;
});
}
}
/*
* 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 {customElement, html, LitElement, query, property, TemplateResult} from 'lit-element';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import {PageTree} from '../PageTree/PageTree';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {TreeNode} from './TreeNode';
import {TreeNodeSelection, Toolbar} from '../SvgTree';
import {until} from 'lit-html/directives/until';
import ElementBrowser = require('TYPO3/CMS/Recordlist/ElementBrowser');
import LinkBrowser = require('TYPO3/CMS/Recordlist/LinkBrowser');
import 'TYPO3/CMS/Backend/Element/IconElement';
import Persistent from 'TYPO3/CMS/Backend/Storage/Persistent';
const componentName: string = 'typo3-backend-component-page-browser';