TeaWeb/shared/js/ui/tree/RendererView.tsx

249 lines
No EOL
8.6 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;
}
@EventHandler<ChannelTreeUIEvents>("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.scrollOffset;
this.setState({smoothScroll: true});
});
}
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;
}
}