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 (
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 { 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(e => e.props.events) @BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) export class ChannelTreeView extends ReactComponentBase { public static readonly EntryHeightEm = 1.3; private readonly refContainer = React.createRef(); 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("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 (
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: [] }); }} >
{elements}
) } 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; } }