import { Menu, MenuItem } from "@electron/remote"; import React from "react"; import { IObservableValue, action, makeObservable, runInAction } from "mobx"; import { observer } from "mobx-react"; import classNames from "classnames"; import { Icon } from "eez-studio-ui/icon"; import type { History, IAppStore, SelectHistoryItemsSpecification } from "instrument/window/history/history"; import type { IHistoryItem } from "instrument/window/history/item"; import { historySessions, SESSION_FREE_ID } from "./session/store"; //////////////////////////////////////////////////////////////////////////////// const CONF_AUTO_RELOAD_TIMEOUT = 1000 / 30; const CONF_AUTO_RELOAD_Y_THRESHOLD = 20; export const CLIPBOARD_DATA_TYPE = "application/eez-studio-history-item"; //////////////////////////////////////////////////////////////////////////////// export class ErrorBoundary extends React.Component< { children?: React.ReactNode; id: string }, { hasError: boolean } > { constructor(props: any) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: any) { return { hasError: true }; } componentDidCatch(error: any, info: any) { console.error(error, info); } render() { if (this.state.hasError) { return (
Error while rendering history item {this.props.id}!
); } return this.props.children; } } //////////////////////////////////////////////////////////////////////////////// export interface ISelection { items: IHistoryItem[]; selectItems(historyItems: IHistoryItem[]): void; } export const HistoryItems = observer( class HistoryItems extends React.Component<{ appStore: IAppStore; historyItems: IHistoryItem[]; selection: ISelection; selectHistoryItemsSpecification: | SelectHistoryItemsSpecification | undefined; getAllItemsBetween: ( fromItem: IHistoryItem, toItem: IHistoryItem ) => IHistoryItem[]; isDeletedItemsHistory: boolean; deleteSelectedHistoryItems: () => void; viewType: "chat" | "thumbs"; thumbnailSize: number; showInHistory?: () => void; }> { render() { return this.props.historyItems.map(historyItem => { let element = historyItem.getListItemElement( this.props.appStore, this.props.viewType ); let showCheckbox = false; if (this.props.selectHistoryItemsSpecification) { if ( this.props.selectHistoryItemsSpecification .historyItemType === "chart" ) { if (historyItem.canBePartOfMultiChart) { showCheckbox = true; } else { element =
; } } else { showCheckbox = true; } } let className = classNames( `EezStudio_HistoryItemEnclosure EezStudio_HistoryItem_${historyItem.id}`, { selected: !this.props.selectHistoryItemsSpecification && historyItem.selected, disablePreview: showCheckbox }, this.props.viewType ); let style; if (this.props.viewType === "thumbs") { style = { "--historyItemThumbnailSize": this.props.thumbnailSize + "px" } as React.CSSProperties; } return (
{ if (event.target instanceof HTMLAnchorElement) { // ignore return; } if (this.props.selectHistoryItemsSpecification) { return; } // this is to prevent text selection with the SHIFT key if (event.shiftKey) { event.preventDefault(); } }} onClick={event => { if (this.props.selectHistoryItemsSpecification) { return; } if (event.target instanceof HTMLAnchorElement) { // ignore return; } if ( $(event.target).parents( "#EezStudio_ModalContent" ).length ) { // ignore clicks on history items with preview in zoom mode return; } let historyItems; if (event.ctrlKey) { if (historyItem.selected) { historyItems = this.props.selection.items.slice(); historyItems.splice( historyItems.indexOf(historyItem), 1 ); } else { historyItems = this.props.selection.items.concat([ historyItem ]); } } else if (event.shiftKey) { if (this.props.selection.items.length > 0) { historyItems = this.props.getAllItemsBetween( this.props.selection.items[0], historyItem ); } else { historyItems = [historyItem]; } } else { historyItems = [historyItem]; } this.props.selection.selectItems(historyItems); event.preventDefault(); }} onContextMenu={event => { event.preventDefault(); event.stopPropagation(); if (this.props.selectHistoryItemsSpecification) { return; } if (!historyItem.selected) { this.props.selection.selectItems([historyItem]); } const menu = new Menu(); if (this.props.isDeletedItemsHistory) { menu.append( new MenuItem({ label: "Restore", click: () => { this.props.appStore.deletedItemsHistory.restoreSelectedHistoryItems(); } }) ); menu.append( new MenuItem({ label: "Purge", click: () => { this.props.appStore.deletedItemsHistory.deleteSelectedHistoryItems(); } }) ); } else { console.log( historyItem.sid, historySessions.selectedSession.id ); if ( (this.props.showInHistory && historyItem.sid == historySessions.selectedSession .id) || (historyItem.sid == null && historySessions.selectedSession.id == SESSION_FREE_ID) ) { menu.append( new MenuItem({ label: "Show in History", click: this.props.showInHistory }) ); } menu.append( new MenuItem({ label: "Delete", click: () => { this.props.deleteSelectedHistoryItems(); } }) ); } menu.popup({}); }} draggable={true} onDragStart={event => { console.log(event); if ( event.target instanceof HTMLVideoElement || event.target instanceof HTMLAudioElement ) { return; } event.stopPropagation(); event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData( CLIPBOARD_DATA_TYPE, historyItem.id ); }} onDrag={() => false} > {showCheckbox && ( { this.props.appStore.selectHistoryItem( historyItem.id, event.target.checked ); }} /> )} {element}
); }); } } ); class LoadMoreButton extends React.Component<{ icon: string; loadMore: () => void; }> { render() { const { icon, loadMore } = this.props; return ( ); } } //////////////////////////////////////////////////////////////////////////////// export class HistoryListComponentClass extends React.Component<{ appStore: IAppStore; history: History; jumpToPresentCondition: IObservableValue; }> { animationFrameRequestId: any; fromBottom: number | undefined; fromTop: number | undefined = 0; autoReloadEnabled: boolean = true; timeOfLastAutoLoad: number; lastScrollHeight: number; lastClientHeight: number; findCenterItemTimeut: any; lastItemInTheCenterId: string | undefined; ref = React.createRef(); get div() { return this.ref.current!.parentElement as Element; } constructor(props: any) { super(props); makeObservable(this, { showHistoryItem: action }); } componentDidMount() { this.autoScroll(); this.div.addEventListener("scroll", this.onScroll); this.lastItemInTheCenterId = undefined; } componentDidUpdate(prevProps: any) { if (this.props.history != prevProps.history) { this.fromBottom = undefined; this.fromTop = 0; } // // make sure scroll bar is recalculated after render // $(this.div).css("overflow", "hidden"); // setTimeout(() => { // $(this.div).css("overflow", "auto"); // }, 1); this.lastItemInTheCenterId = undefined; } componentWillUnmount() { window.cancelAnimationFrame(this.animationFrameRequestId); this.div.removeEventListener("scroll", this.onScroll); } moveToTop() { this.fromBottom = 0; this.fromTop = undefined; } moveToBottom() { this.fromBottom = undefined; this.fromTop = 0; } showHistoryItem(historyItem: IHistoryItem) { this.autoReloadEnabled = false; let c = 0; const scrollIntoView = () => { const element = $(this.div).find( `.EezStudio_HistoryItem_${historyItem.id}` )[0]; if (element) { element.scrollIntoView({ block: "nearest" }); } if (++c < 5) { setTimeout(scrollIntoView, 10); } else { this.autoReloadEnabled = true; } }; scrollIntoView(); } autoScroll = () => { if ($(this.div).is(":visible")) { if (this.fromBottom !== undefined) { if (this.fromBottom != this.div.scrollTop) { this.div.scrollTop = this.fromBottom; } } else if (this.fromTop !== undefined) { let scrollTop = this.div.scrollHeight - this.div.clientHeight - this.fromTop; if (scrollTop != this.div.scrollTop) { this.div.scrollTop = scrollTop; } } } const jumpToPresentCondition = this.div.scrollHeight - (this.div.scrollTop + this.div.clientHeight) > this.div.clientHeight; if (jumpToPresentCondition != this.props.jumpToPresentCondition.get()) { runInAction(() => this.props.jumpToPresentCondition.set(jumpToPresentCondition) ); } // automatically load more content const time = Date.now(); if ( this.autoReloadEnabled && this.div.clientHeight > 0 && this.div.scrollHeight >= this.div.clientHeight && (this.timeOfLastAutoLoad === undefined || time - this.timeOfLastAutoLoad > CONF_AUTO_RELOAD_TIMEOUT) ) { this.timeOfLastAutoLoad = Date.now(); if ( this.props.history.navigator.hasOlder && this.div.scrollTop < CONF_AUTO_RELOAD_Y_THRESHOLD ) { this.loadOlder(); } if ( this.props.history.navigator.hasNewer && this.div.scrollHeight - (this.div.scrollTop + this.div.clientHeight) < CONF_AUTO_RELOAD_Y_THRESHOLD ) { this.loadNewer(); } } this.animationFrameRequestId = window.requestAnimationFrame( this.autoScroll ); }; onScroll = (event: any) => { if ( this.div.scrollHeight === this.lastScrollHeight && this.div.clientHeight === this.lastClientHeight ) { if (this.fromBottom !== undefined) { this.fromBottom = this.div.scrollTop; } else if (this.fromTop !== undefined) { this.fromTop = this.div.scrollHeight - this.div.clientHeight - this.div.scrollTop; } } this.lastScrollHeight = this.div.scrollHeight; this.lastClientHeight = this.div.clientHeight; // find item in the center of the view if (this.findCenterItemTimeut) { clearTimeout(this.findCenterItemTimeut); } this.findCenterItemTimeut = setTimeout(() => { this.findCenterItemTimeut = undefined; const itemElements = $(this.div).find( ".EezStudio_HistoryItemEnclosure" ); if (itemElements.length > 0) { let foundItemElement = itemElements[0]; const rect = foundItemElement.getBoundingClientRect(); let minDistance = Math.abs( this.div.clientHeight - (rect.top + rect.height / 2) ); for (let i = 1; i < itemElements.length; ++i) { const rect = itemElements[i].getBoundingClientRect(); const distance = Math.abs( this.div.clientHeight / 2 - (rect.top + rect.height / 2) ); if (distance < minDistance) { minDistance = distance; foundItemElement = itemElements[i]; } } const matches = foundItemElement.className.match( /EezStudio_HistoryItem_(\d*)/ ); if (matches && matches.length >= 1) { const itemId = matches[1]; if (itemId !== this.lastItemInTheCenterId) { // found it this.props.history.setItemInTheCenterOfTheView(itemId); } } } }, 100); }; loadOlder = async () => { this.autoReloadEnabled = false; this.fromBottom = undefined; this.fromTop = undefined; // when we load older items, we don't want scroll position to change // find first item above scrollTop let firstItem: HTMLDivElement | undefined; const items = this.div.children[0].children; for (let i = 0; i < items.length; ++i) { const item = items[i] as HTMLDivElement; if ( item.className.indexOf("EezStudio_HistoryItemEnclosure") !== -1 && item.offsetTop > this.div.scrollTop ) { firstItem = item; break; } } if (firstItem) { // remember offset of the firstItem from scrollTop let offset = firstItem.offsetTop - this.div.scrollTop; await this.props.history.navigator.loadOlder(); window.setTimeout(() => { // make sure firstItem is again at the same offset this.div.scrollTop = firstItem!.offsetTop - offset; this.autoReloadEnabled = true; }, 5); } else { await this.props.history.navigator.loadOlder(); } }; loadNewer = () => { this.fromBottom = undefined; this.fromTop = undefined; this.props.history.navigator.loadNewer(); }; render() { return (
{ if ( $(event.target).closest( ".EezStudio_HistoryItemEnclosure" ).length === 0 ) { this.props.history.selection.selectItems([]); } }} > {this.props.history.navigator.hasOlder && ( )} this.props.history.getAllItemsBetween(fromItem, toItem) } isDeletedItemsHistory={ this.props.history === this.props.appStore.deletedItemsHistory } deleteSelectedHistoryItems={() => this.props.history.deleteSelectedHistoryItems() } viewType="chat" thumbnailSize={480} /> {this.props.history.navigator.hasNewer && ( )}
); } } export const HistoryListComponent = observer(HistoryListComponentClass);