import {
BatchUpdateAssignment,
BatchUpdateType,
ReactComponentBase
} from "tc-shared/ui/react-elements/ReactComponentBase";
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
import * as React from "react";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import ResizeObserver from 'resize-observer-polyfill';
import {RDPChannelTree, RDPEntry} from "./RendererDataProvider";
import {RendererMove} from "./RendererMove";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
const viewStyle = require("./View.scss");
export class PopoutButton extends React.Component<{ tree: RDPChannelTree }, {}> {
render() {
if(!this.props.tree.popoutButtonShown) {
return null;
}
return (
this.props.tree.events.fire("action_toggle_popout", { shown: !this.props.tree.popoutShown })}>
);
}
}
export interface ChannelTreeViewProperties {
events: Registry;
dataProvider: RDPChannelTree;
moveThreshold?: number;
}
export interface ChannelTreeViewState {
element_scroll_offset?: number; /* in px */
scroll_offset: number; /* in px */
view_height: number; /* in px */
tree_version: number;
smoothScroll: boolean;
/* the currently rendered tree */
tree: RDPEntry[];
treeRevision: number;
}
@ReactEventHandler(e => e.props.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelTreeView extends ReactComponentBase {
public static readonly EntryHeight = 18;
private readonly refContainer = React.createRef();
private resizeObserver: ResizeObserver;
private scrollFixRequested;
private mouseMove: { x: number, y: number, down: boolean, fired: boolean } = {
x: 0,
y: 0,
down: false,
fired: false
};
private readonly documentMouseListener;
private inViewCallbacks: {
index: number,
callback: () => void,
timeout
}[] = [];
constructor(props) {
super(props);
this.state = {
scroll_offset: 0,
view_height: 0,
tree_version: 0,
smoothScroll: false,
tree: [],
treeRevision: -1
};
this.documentMouseListener = (e: MouseEvent) => {
if (e.type !== "mouseleave" && e.button !== 0)
return;
this.mouseMove.down = false;
this.mouseMove.fired = false;
this.removeDocumentMouseListener();
};
}
componentDidMount(): void {
this.resizeObserver = new ResizeObserver(entries => {
if (entries.length !== 1) {
if (entries.length === 0) {
console.warn(tr("Channel resize observer fired resize event with no entries!"));
} else {
console.warn(tr("Channel resize observer fired resize event with more than one entry which should not be possible (%d)!"), entries.length);
}
return;
}
const bounds = entries[0].contentRect;
if (this.state.view_height !== bounds.height) {
this.setState({
view_height: bounds.height
});
}
});
this.resizeObserver.observe(this.refContainer.current);
this.setState({ tree: this.props.dataProvider.getTreeEntries() });
}
componentWillUnmount(): void {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
@EventHandler("notify_visibility_changed")
private handleVisibilityChanged(event: ChannelTreeUIEvents["notify_visibility_changed"]) {
if (!event.visible) {
this.setState({smoothScroll: false});
return;
}
if (this.scrollFixRequested) {
return;
}
this.scrollFixRequested = true;
requestAnimationFrame(() => {
this.scrollFixRequested = false;
this.refContainer.current.scrollTop = this.state.scroll_offset;
this.setState({smoothScroll: true});
});
}
private registerDocumentMouseListener() {
document.addEventListener("mouseleave", this.documentMouseListener);
document.addEventListener("mouseup", this.documentMouseListener);
}
private removeDocumentMouseListener() {
document.removeEventListener("mouseleave", this.documentMouseListener);
document.removeEventListener("mouseup", this.documentMouseListener);
}
private visibleEntries() {
let viewEntryCount = Math.ceil(this.state.view_height / ChannelTreeView.EntryHeight);
const viewEntryBegin = Math.floor(this.state.scroll_offset / ChannelTreeView.EntryHeight);
const viewEntryEnd = Math.min(this.state.tree.length, viewEntryBegin + viewEntryCount);
return {
begin: viewEntryBegin,
end: viewEntryEnd
}
}
render() {
const entryPreRenderCount = 5;
const entryPostRenderCount = 5;
const elements = [];
const renderedRange = this.visibleEntries();
const viewEntryBegin = Math.max(0, renderedRange.begin - entryPreRenderCount);
const viewEntryEnd = Math.min(this.state.tree.length, renderedRange.end + entryPostRenderCount);
for (let index = viewEntryBegin; index < viewEntryEnd; index++) {
elements.push(this.state.tree[index].render());
}
for (const callback of this.inViewCallbacks.slice(0)) {
if (callback.index >= renderedRange.begin && callback.index <= renderedRange.end) {
clearTimeout(callback.timeout);
callback.callback();
this.inViewCallbacks.remove(callback);
}
}
return (
this.onScroll()}
ref={this.refContainer}
onMouseDown={e => this.onMouseDown(e)}
onMouseMove={e => this.onMouseMove(e)}
onContextMenu={event => {
if(event.target !== this.refContainer.current) { return; }
event.preventDefault();
this.props.events.fire("action_show_context_menu", { pageY: event.pageY, pageX: event.pageX, treeEntryId: 0 });
}}
>
{elements}
{
const targetEntry = this.getEntryFromPoint(target.x, target.y);
this.props.events.fire("action_move_entries", { treeEntryId: typeof targetEntry === "number" ? targetEntry : 0 });
}}
onMoveCancel={() => {
this.props.events.fire("action_move_entries", { treeEntryId: 0 });
}}
ref={this.props.dataProvider.refMove}
/>
)
}
private onScroll() {
this.setState({
scroll_offset: this.refContainer.current.scrollTop
});
}
private onMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return; /* left button only */
this.mouseMove.down = true;
this.mouseMove.x = e.pageX;
this.mouseMove.y = e.pageY;
this.registerDocumentMouseListener();
}
private onMouseMove(e: React.MouseEvent) {
if (!this.mouseMove.down || this.mouseMove.fired) return;
if (Math.abs((this.mouseMove.x - e.pageX) * (this.mouseMove.y - e.pageY)) > (this.props.moveThreshold || 9)) {
this.mouseMove.fired = true;
this.props.events.fire("action_start_entry_move", {
current: { x: e.pageX, y: e.pageY },
start: { x: this.mouseMove.x, y: this.mouseMove.y }
});
}
}
scrollEntryInView(entryId: number, callback?: () => void) {
const index = this.state.tree.findIndex(e => e.entryId === entryId);
if (index === -1) {
if (callback) callback();
console.warn(tr("Failed to scroll tree entry in view because its not registered within the view. EntryId: %d"), entryId);
return;
}
let new_index;
const currentRange = this.visibleEntries();
if (index >= currentRange.end - 1) {
new_index = index - (currentRange.end - currentRange.begin) + 2;
} else if (index < currentRange.begin) {
new_index = index;
} else {
if (callback) callback();
return;
}
this.refContainer.current.scrollTop = new_index * ChannelTreeView.EntryHeight;
if (callback) {
let cb = {
index: index,
callback: callback,
timeout: setTimeout(() => {
this.inViewCallbacks.remove(cb);
callback();
}, (Math.abs(new_index - currentRange.begin) / (currentRange.end - currentRange.begin)) * 1500)
};
this.inViewCallbacks.push(cb);
}
}
getEntryFromPoint(pageX: number, pageY: number) : number {
const container = this.refContainer.current;
if (!container) return;
const bounds = container.getBoundingClientRect();
pageY -= bounds.y;
pageX -= bounds.x;
if (pageX < 0 || pageY < 0)
return undefined;
if (pageX > container.clientWidth)
return undefined;
const total_offset = container.scrollTop + pageY;
return this.state.tree[Math.floor(total_offset / ChannelTreeView.EntryHeight)]?.entryId;
}
}