230 lines
No EOL
8 KiB
TypeScript
230 lines
No EOL
8 KiB
TypeScript
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 {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
|
|
import {ClientIcon} from "svg-sprites/client-icons";
|
|
import {LogCategory, logWarn} from "tc-shared/log";
|
|
|
|
const viewStyle = require("./View.scss");
|
|
|
|
export class PopoutButton extends React.Component<{ tree: RDPChannelTree }, {}> {
|
|
render() {
|
|
if(!this.props.tree.popoutButtonShown) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={viewStyle.popoutButton} onClick={() => this.props.tree.events.fire("action_toggle_popout", { shown: !this.props.tree.popoutShown })}>
|
|
<div className={viewStyle.button} title={this.props.tree.popoutShown ? tr("Popin the second channel tree view") : tr("Popout the channel tree view")}>
|
|
<ClientIconRenderer icon={this.props.tree.popoutShown ? ClientIcon.ChannelPopin : ClientIcon.ChannelPopout} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export interface ChannelTreeViewProperties {
|
|
events: Registry<ChannelTreeUIEvents>;
|
|
dataProvider: RDPChannelTree;
|
|
moveThreshold?: number;
|
|
}
|
|
|
|
export interface ChannelTreeViewState {
|
|
elementScrollOffset?: number; /* in px */
|
|
scrollOffset: number; /* in px */
|
|
viewHeight: number; /* in px */
|
|
fontSize: number; /* in px */
|
|
|
|
treeVersion: number;
|
|
smoothScroll: boolean;
|
|
|
|
/* the currently rendered tree */
|
|
tree: RDPEntry[];
|
|
treeRevision: number;
|
|
}
|
|
|
|
@ReactEventHandler<ChannelTreeView>(e => e.props.events)
|
|
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
|
|
export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewProperties, ChannelTreeViewState> {
|
|
public static readonly EntryHeightEm = 1.3;
|
|
|
|
private readonly refContainer = React.createRef<HTMLDivElement>();
|
|
private resizeObserver: ResizeObserver;
|
|
|
|
private scrollFixRequested;
|
|
|
|
private inViewCallbacks: {
|
|
index: number,
|
|
callback: () => void,
|
|
timeout
|
|
}[] = [];
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
scrollOffset: 0,
|
|
viewHeight: 0,
|
|
treeVersion: 0,
|
|
smoothScroll: false,
|
|
tree: [],
|
|
treeRevision: -1,
|
|
fontSize: 14
|
|
};
|
|
}
|
|
|
|
componentDidMount(): void {
|
|
this.resizeObserver = new ResizeObserver(entries => {
|
|
if (entries.length !== 1) {
|
|
if (entries.length === 0) {
|
|
logWarn(LogCategory.GENERAL, tr("Channel resize observer fired resize event with no entries!"));
|
|
} else {
|
|
logWarn(LogCategory.GENERAL, 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;
|
|
const fontSize = parseFloat(getComputedStyle(entries[0].target).getPropertyValue("font-size"));
|
|
if (this.state.viewHeight !== bounds.height || this.state.fontSize !== fontSize) {
|
|
this.setState({
|
|
viewHeight: bounds.height,
|
|
fontSize: fontSize
|
|
});
|
|
}
|
|
});
|
|
|
|
this.resizeObserver.observe(this.refContainer.current);
|
|
this.setState({ tree: this.props.dataProvider.getTreeEntries() });
|
|
}
|
|
|
|
componentWillUnmount(): void {
|
|
this.resizeObserver.disconnect();
|
|
this.resizeObserver = undefined;
|
|
}
|
|
|
|
private visibleEntries() {
|
|
const entryHeight = ChannelTreeView.EntryHeightEm * this.state.fontSize;
|
|
let viewEntryCount = Math.ceil(this.state.viewHeight / entryHeight);
|
|
const viewEntryBegin = Math.floor(this.state.scrollOffset / 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 (
|
|
<div
|
|
className={viewStyle.channelTreeContainer + " " + (this.state.smoothScroll ? viewStyle.smoothScroll : "")}
|
|
onScroll={() => this.onScroll()}
|
|
ref={this.refContainer}
|
|
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, treeEntryIds: [] });
|
|
}}
|
|
>
|
|
<div
|
|
className={viewStyle.channelTree}
|
|
style={{height: (this.state.tree.length * ChannelTreeView.EntryHeightEm) + "em"}}>
|
|
{elements}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
private onScroll() {
|
|
this.setState({
|
|
scrollOffset: this.refContainer.current.scrollTop
|
|
});
|
|
}
|
|
|
|
scrollEntryInView(entryId: number, callback?: () => void) {
|
|
const index = this.state.tree.findIndex(e => e.entryId === entryId);
|
|
if (index === -1) {
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
logWarn(LogCategory.GENERAL, tr("Failed to scroll tree entry in view because its not registered within the view. EntryId: %d"), entryId);
|
|
return;
|
|
}
|
|
|
|
let newIndex;
|
|
const currentRange = this.visibleEntries();
|
|
if (index >= currentRange.end - 1) {
|
|
newIndex = index - (currentRange.end - currentRange.begin) + 2;
|
|
} else if (index < currentRange.begin) {
|
|
newIndex = index;
|
|
} else {
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.refContainer.current.scrollTop = newIndex * ChannelTreeView.EntryHeightEm * this.state.fontSize;
|
|
|
|
if (callback) {
|
|
let cb = {
|
|
index: index,
|
|
callback: callback,
|
|
timeout: setTimeout(() => {
|
|
this.inViewCallbacks.remove(cb);
|
|
callback();
|
|
}, (Math.abs(newIndex - 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 totalOffset = container.scrollTop + pageY;
|
|
return this.state.tree[Math.floor(totalOffset / (ChannelTreeView.EntryHeightEm * this.state.fontSize))]?.entryId;
|
|
}
|
|
} |