Files
vscode-debug-visualizer/extension/ui/src/Visualizers/TreeVisualizer/Views.tsx
2019-09-06 16:28:08 +02:00

524 lines
11 KiB
TypeScript

import { observer, disposeOnUnmount } from "mobx-react";
import { observable, autorun, computed, action, runInAction } from "mobx";
import React = require("react");
import classNames = require("classnames");
import { Rectangle, Point } from "../../Components/Point";
import { SvgLine } from "../../Components/SvgElements";
import "./style.scss";
export class TreeViewModel<TData = unknown> {
private started = true;
start() {
this.started = true;
this.updateOffsets();
}
stop() {
this.started = false;
}
private version = 0;
private lastUpdateVersion = -1;
@observable public root: TreeNodeViewModel<TData> | undefined = undefined;
public invalidateOffsets() {
this.version++;
}
@action
public updateOffsets() {
if (!this.started) {
return;
}
if (!this.root) {
return;
}
if (this.lastUpdateVersion === this.version) {
return;
}
this.lastUpdateVersion = this.version;
console.log("update");
this.root.updateOffsets();
}
public get marked(): TreeNodeViewModel<TData>[] {
const result = new Array<TreeNodeViewModel<TData>>();
function recurse(node: TreeNodeViewModel<TData>) {
if (node.isMarked) {
result.push(node);
}
for (const c of node.children) {
recurse(c);
}
}
if (this.root) {
recurse(this.root);
}
return result;
}
@observable selected: TreeNodeViewModel<TData> | undefined = undefined;
@action public select(current: TreeNodeViewModel<TData> | undefined) {
if (this.selected) {
this.selected.isSelected = false;
}
if (current) {
current.isSelected = true;
}
this.selected = current;
}
@action public toggleSelect(current: TreeNodeViewModel<TData>) {
if (this.selected === current) {
this.select(undefined);
} else {
this.select(current);
}
}
private selectOnHoverEnabled = false;
private selectedBeforeHover:
| TreeNodeViewModel<TData>
| undefined = undefined;
@action public hover(current: TreeNodeViewModel<TData> | undefined) {
if (this.selectOnHoverEnabled) {
if (current) {
this.select(current);
} else {
this.select(this.selectedBeforeHover);
}
}
}
selectOnHover(enable: boolean) {
if (this.selectOnHoverEnabled === enable) {
return;
}
if (enable) {
this.selectedBeforeHover = this.selected;
} else if (!this.selected) {
this.select(this.selectedBeforeHover);
}
this.selectOnHoverEnabled = enable;
}
}
let i = 0;
export class TreeNodeViewModel<TData = unknown> {
public parent: TreeNodeViewModel<TData> | null = null;
constructor(
public readonly treeViewModel: TreeViewModel<TData>,
public readonly id: string | undefined,
public readonly name: string,
public readonly value: string | undefined,
public readonly emphasizedValue: string | undefined,
public readonly children: TreeNodeViewModel<TData>[],
public readonly data: TData
) {}
@observable expanderOffsetRect: Rectangle | undefined = undefined;
private expanderDiv: HTMLDivElement | null = null;
@action.bound
setExpanderDiv(div: HTMLDivElement | null) {
this.expanderDiv = div;
this.treeViewModel.invalidateOffsets();
}
@observable public expanded: boolean = false;
@observable public isMarked: boolean = false;
@observable public isSelected: boolean = false;
@action
public toggleExpanded() {
this.expanded = !this.expanded;
this.treeViewModel.invalidateOffsets();
}
@computed public get isChildOrThisMarked(): boolean {
if (this.isMarked) return true;
return this.children.some(c => c.isChildOrThisMarked);
}
@computed public get isChildOrThisSelected(): boolean {
if (this.isSelected) return true;
return this.children.some(c => c.isChildOrThisSelected);
}
@computed public get isParentOrThisSelected(): boolean {
if (this.isSelected) return true;
if (this.parent) return this.parent.isParentOrThisSelected;
return false;
}
get path(): string[] {
const result = new Array<string>();
let c: TreeNodeViewModel = this;
while (c.parent) {
result.unshift(c.id || c.name);
c = c.parent;
}
if (result.length === 0) {
result.push("root");
}
return result;
}
updateOffsets() {
for (const c of this.children) {
c.updateOffsets();
}
if (!this.expanderDiv) {
return;
}
const newRect = Rectangle.ofSize(
new Point(this.expanderDiv.offsetLeft, this.expanderDiv.offsetTop),
new Point(
this.expanderDiv.offsetWidth,
this.expanderDiv.offsetHeight
)
);
if (
!this.expanderOffsetRect ||
!newRect.equals(this.expanderOffsetRect)
) {
this.expanderOffsetRect = newRect;
}
}
}
const isValidFunctionName = (function() {
var validName = /^[$A-Z_][0-9A-Z_$]*$/i;
var reserved = {
abstract: true,
boolean: true,
// ...
with: true,
} as any;
return function(s: string) {
// Ensure a valid name and not reserved.
return validName.test(s) && !reserved[s];
};
})();
@observer
export class TreeWithPathView extends React.Component<{
model: TreeViewModel;
}> {
render() {
const model = this.props.model;
return (
<div className="component-TreeWithPathView">
<div className="part-path">
<span>
{model.selected ? (
<span>
{model.selected.path.reduce((acc, v) => {
acc = acc.slice();
function add() {
acc.push(
<span
className="part-path-item"
key={acc.length}
>
{v}
</span>
);
}
if (isValidFunctionName(v)) {
if (acc.length > 0) {
acc.push(
<span key={acc.length}>.</span>
);
}
add();
} else {
acc.push(
<span key={acc.length}>[</span>
);
add();
acc.push(
<span key={acc.length}>]</span>
);
}
return acc;
}, new Array<React.ReactElement>())}
</span>
) : (
"(Nothing Selected)"
)}
</span>
</div>
<div className="part-tree">
<TreeView model={model} />
</div>
</div>
);
}
}
@observer
export class TreeView extends React.Component<{ model: TreeViewModel }> {
componentWillUpdate(newProps: this["props"]) {
newProps.model.stop();
}
componentWillMount() {
this.props.model.stop();
}
componentDidUpdate() {
this.props.model.start();
}
componentDidMount() {
this.props.model.start();
}
render() {
const model = this.props.model;
return (
<div
tabIndex={0}
className="component-TreeView"
onKeyDown={e => {
if (e.keyCode === 18) {
// alt
e.preventDefault();
e.stopPropagation();
model.selectOnHover(true);
}
}}
onKeyUp={e => {
if (e.keyCode === 18) {
// alt
e.preventDefault();
e.stopPropagation();
model.selectOnHover(false);
}
}}
>
{model.root && (
<>
<svg className="part-svg">
<TreeNodeSvgView model={model.root} />
</svg>
<div className="part-node">
<TreeNodeView model={model.root} />
</div>
</>
)}
</div>
);
}
}
@observer
export class TreeNodeSvgView extends React.Component<{
model: TreeNodeViewModel;
}> {
render() {
const model = this.props.model;
if (
!model.expanderOffsetRect ||
model.children.length === 0 ||
model.expanded
) {
return <g />;
}
function findLastExpanderOffset(
predicate: (v: TreeNodeViewModel) => boolean
): Rectangle | undefined {
const all = model.children
.filter(predicate)
.map(c => c.expanderOffsetRect);
const last = all[all.length - 1];
return last;
}
const lastOffset = findLastExpanderOffset(() => true);
const selectedOffset = findLastExpanderOffset(
v => v.isChildOrThisSelected
);
const markedOffset = findLastExpanderOffset(v => v.isChildOrThisMarked);
if (!lastOffset) {
return <g />;
}
const start = model.expanderOffsetRect.center.add({ y: 10 });
return (
<g className="component-TreeNodeSvgView">
{markedOffset && (
<SvgLine
className="path childOrThisMarked"
start={start}
end={new Point(start.x, markedOffset.center.y)}
/>
)}
{selectedOffset && (
<SvgLine
className="path childOrThisSelected"
start={start}
end={new Point(start.x, selectedOffset.center.y)}
/>
)}
<SvgLine
className="path"
start={start}
end={new Point(start.x, lastOffset.center.y)}
/>
{model.children.map((c, idx) => {
const end = c.expanderOffsetRect!.center.sub({ x: 5 });
return (
<g key={idx}>
{c.isChildOrThisMarked && (
<SvgLine
className={classNames(
"path",
"childOrThisMarked"
)}
start={new Point(start.x, end.y)}
end={end}
/>
)}
<SvgLine
className={classNames(
"path",
c.isChildOrThisSelected &&
"childOrThisSelected"
)}
start={new Point(start.x, end.y)}
end={end}
/>
</g>
);
})}
{model.children.map((c, idx) => (
<TreeNodeSvgView key={idx} model={c} />
))}
</g>
);
}
}
@observer
export class TreeNodeView extends React.Component<{
model: TreeNodeViewModel;
}> {
@action.bound
private clickHandler() {
this.props.model.treeViewModel.toggleSelect(this.props.model);
}
private mouseEnterOrLeaveHandler(enter: boolean) {
this.props.model.treeViewModel.hover(
enter ? this.props.model : undefined
);
}
@observable
private rootDiv: HTMLElement | null = null;
@action.bound
private setRootDiv(div: HTMLElement | null) {
this.rootDiv = div;
}
@disposeOnUnmount
private readonly _autorun = autorun(() => {
if (this.props.model.isMarked && this.rootDiv) {
this.rootDiv.scrollIntoView();
}
});
public componentDidMount() {
this.props.model.treeViewModel.updateOffsets();
}
public componentDidUpdate() {
this.props.model.treeViewModel.updateOffsets();
}
public render(): JSX.Element {
const model = this.props.model;
const collapsed = model.expanded;
return (
<div
className={classNames(
"component-TreeNodeView",
model.expanded && "collapsed",
model.isMarked && "selected",
model.isSelected && "hovered",
model.isChildOrThisSelected &&
!model.isSelected &&
"childHovered",
model.isParentOrThisSelected &&
!model.isSelected &&
"parentHovered"
)}
ref={this.setRootDiv}
onMouseOver={e => {
this.mouseEnterOrLeaveHandler(true);
e.stopPropagation();
}}
onMouseOut={e => {
this.mouseEnterOrLeaveHandler(false);
e.stopPropagation();
}}
>
<div className="part-header">
<div
ref={model.setExpanderDiv}
onClick={() => model.toggleExpanded()}
className={classNames(
"part-collapser",
model.children.length > 0 &&
"decorator-collapse-icon",
!collapsed && "expanded"
)}
/>
<span className="part-text" onClick={this.clickHandler}>
{model.id && (
<>
<span className="part-id">{model.id}</span>
<span>: </span>
</>
)}
<span className="part-name">{model.name}</span>
{model.value && (
<span className="part-value">{model.value}</span>
)}
{model.emphasizedValue && (
<span className="part-emphasized-value">
{model.emphasizedValue}
</span>
)}
</span>
</div>
{model.children.length > 0 && !model.expanded && (
<div className="part-children">
{model.children.map((c, idx) => (
<TreeNodeView model={c} key={idx} />
))}
</div>
)}
</div>
);
}
}