import React from "react"; import { observable, computed, action, makeObservable } from "mobx"; import { observer } from "mobx-react"; import classNames from "classnames"; import { hasClass } from "eez-studio-shared/dom"; import { Icon } from "eez-studio-ui/icon"; import { TreeAdapter, DropPosition, TreeObjectAdapter } from "project-editor/core/objectAdapter"; import { ProjectContext } from "project-editor/project/context"; //////////////////////////////////////////////////////////////////////////////// const DropMark = observer( ({ left, top, width, verticalConnectionLineHeight }: { left: number; top: number; width: number; verticalConnectionLineHeight: number | undefined; }) => (
{verticalConnectionLineHeight !== undefined && (
)}
) ); //////////////////////////////////////////////////////////////////////////////// const TreeRow = observer( class TreeRow extends React.Component<{ treeAdapter: TreeAdapter; item: TreeObjectAdapter; level: number; draggable: boolean; onDragStart: (event: any) => void; onDrag: (event: any) => void; onDragEnd: (event: any) => void; onClick: (event: any) => void; onMouseUp: (event: any) => void; onDoubleClick: (event: any) => void; collapsable: boolean; onToggleCollapse: (event: any) => void; onEditItem?: (itemId: string) => void; renderItem?: (itemId: string) => React.ReactNode; }> { ref = React.createRef(); ensureVisibleTimeout: any; componentDidMount() { this.ensureVisibleTimeout = setTimeout(() => { this.ensureVisibleTimeout = undefined; if ( !( this.props.treeAdapter.draggableAdapter && this.props.treeAdapter.draggableAdapter.isDragging ) ) { if (hasClass(this.ref.current, "selected")) { this.ref.current!.scrollIntoView({ block: "center" }); } } }, 100); } componentWillUnmount(): void { if (this.ensureVisibleTimeout) { clearTimeout(this.ensureVisibleTimeout); this.ensureVisibleTimeout = undefined; } } render() { const { treeAdapter, item, level, collapsable, onToggleCollapse, onEditItem } = this.props; let className = classNames("tree-row", { selected: treeAdapter.isSelected(item), "drag-source": treeAdapter.draggableAdapter && treeAdapter.draggableAdapter.isDragSource(item) }); let triangle: JSX.Element | undefined; if (collapsable) { triangle = ( ); } return (
{triangle} {this.props.renderItem ? ( this.props.renderItem(treeAdapter.getItemId(item)) ) : ( {treeAdapter.itemToString(item)} )} {onEditItem && ( onEditItem(treeAdapter.getItemId(item)) } /> )}
); } } ); //////////////////////////////////////////////////////////////////////////////// interface TreeProps { treeAdapter: TreeAdapter; tabIndex?: number; onFocus?: () => void; onEditItem?: (itemId: string) => void; renderItem?: (itemId: string) => React.ReactNode; onFilesDrop?: (files: File[]) => void; } export const Tree = observer( class Tree extends React.Component { static contextType = ProjectContext; declare context: React.ContextType; static defaultProps = { tabIndex: -1, maxLevel: undefined, collapsable: true, sortDirection: "none" }; treeDiv: HTMLDivElement; dropPosition: DropPosition | undefined; dropMarkLeft: number; dropMarkTop: number; dropMarkWidth: number; dropMarkVerticalConnectionLineHeight: number | undefined; constructor(props: TreeProps) { super(props); makeObservable(this, { dropPosition: observable, dropMarkLeft: observable, dropMarkTop: observable, dropMarkWidth: observable, dropMarkVerticalConnectionLineHeight: observable, allRows: computed, onDragOver: action.bound, onDragLeave: action.bound, onDrop: action.bound }); } get allRows() { return this.props.treeAdapter.allRows; } componentDidUpdate(prevProps: any) { if (this.props != prevProps) { this.setState({ dropItem: undefined }); } } onSelect(objectId: string) { let item = this.props.treeAdapter.getItemFromId(objectId); if (item) { this.props.treeAdapter.selectItem(item); } } onKeyDown = (event: any) => { if (event.altKey) { } else if (event.shiftKey) { } else if (event.ctrlKey) { if (event.keyCode == "X".charCodeAt(0)) { event.preventDefault(); event.stopPropagation(); this.props.treeAdapter.cutSelection(); } else if (event.keyCode == "C".charCodeAt(0)) { event.preventDefault(); event.stopPropagation(); this.props.treeAdapter.copySelection(); } else if (event.keyCode == "V".charCodeAt(0)) { event.preventDefault(); event.stopPropagation(); this.context.paste(); } } else { let focusedItemId = $(this.treeDiv) .find(".tree-row.selected") .attr("data-object-id"); if (!focusedItemId) { return; } let $focusedItem = $(this.treeDiv).find( `.tree-row[data-object-id="${focusedItemId}"]` ); if ( event.keyCode == 38 || event.keyCode == 40 || event.keyCode == 33 || event.keyCode == 34 || event.keyCode == 36 || event.keyCode == 35 ) { let $rows = $(this.treeDiv).find(".tree-row"); let index = $rows.index($focusedItem); let pageSize = Math.floor( $(this.treeDiv).height()! / $rows.height()! ); if (event.keyCode == 38) { // up index--; } else if (event.keyCode == 40) { // down index++; } else if (event.keyCode == 33) { // page up index -= pageSize; } else if (event.keyCode == 34) { // page down index += pageSize; } else if (event.keyCode == 36) { // home index = 0; } else if (event.keyCode == 35) { // end index = $rows.length - 1; } if (index < 0) { index = 0; } else if (index >= $rows.length) { index = $rows.length - 1; } let newFocusedItemId = $($rows[index]).attr( "data-object-id" ); if (newFocusedItemId) { this.onSelect(newFocusedItemId); ($rows[index] as Element).scrollIntoView({ block: "nearest", behavior: "auto" }); } event.preventDefault(); } else if (event.keyCode == 37 || event.keyCode == 39) { // left let $rows = $focusedItem.parent().find(".tree-row"); if ($rows.length == 1) { let $row = $($rows[0]); $rows = $row.parent().parent().find(".tree-row"); let newFocusedItemId = $($rows[0]).attr( "data-object-id" ); if (newFocusedItemId) { this.onSelect(newFocusedItemId); } } else { $focusedItem .find(".tree-row-triangle") .trigger("click"); } event.preventDefault(); } else if (event.keyCode == 13) { let item = this.props.treeAdapter.getItemFromId( $focusedItem.attr("data-object-id")! ); if (item) { this.props.treeAdapter.onClick(item); } } } }; onDragOver(event: React.DragEvent) { if (isFileData(event)) { if (this.props.onFilesDrop) { event.stopPropagation(); event.preventDefault(); } return; } if ( event.dataTransfer.types.indexOf( "application/eez-studio-tab" ) >= 0 ) { event.preventDefault(); event.dataTransfer.dropEffect = "none"; return; } const treeAdapter = this.props.treeAdapter; const draggableAdapter = treeAdapter.draggableAdapter!; if (this.props.treeAdapter.draggable) { const $treeDiv = $(this.treeDiv); const $allRows = $treeDiv.find("[data-object-id]"); if ($allRows.length > 0) { const firstRowRect = $allRows .get(0)! .getBoundingClientRect(); const treeDivRect = this.treeDiv.getBoundingClientRect(); let rowIndexAtCursor = Math.floor( (event.nativeEvent.clientY - treeDivRect.top) / firstRowRect.height ); if ( rowIndexAtCursor >= 0 && rowIndexAtCursor < $allRows.length ) { const $row = $allRows.eq(rowIndexAtCursor); const rowRect = $row.get(0)!.getBoundingClientRect(); const $label = $row.find("span"); const labelRect = $label .get(0)! .getBoundingClientRect(); const objectId = $row.attr("data-object-id"); let dropItem = treeAdapter.getItemFromId(objectId!)!; let dropPosition: DropPosition | undefined; let canDrop = false; const CHILD_OFFSET = 25; function checks() { const $row = $treeDiv.find( `[data-object-id="${treeAdapter.getItemId( dropItem )}"]` ); const rowIndexAtCursor = $allRows.index($row); let prevObjectId; if (rowIndexAtCursor > 0) { const $prevRow = $allRows.eq( rowIndexAtCursor - 1 ); prevObjectId = $prevRow.attr("data-object-id"); } let nextObjectId; if (rowIndexAtCursor < $allRows.length - 1) { const $nextRow = $allRows.eq( rowIndexAtCursor + 1 ); nextObjectId = $nextRow.attr("data-object-id"); } canDrop = draggableAdapter.canDrop( dropItem, dropPosition!, prevObjectId, nextObjectId ); } const $nextRow = $allRows.eq(rowIndexAtCursor + 1); const nextObjectId = $nextRow.attr("data-object-id"); let nextItem = nextObjectId ? treeAdapter.getItemFromId(nextObjectId!) : undefined; let nextItemParent = nextItem && treeAdapter.getItemParent(nextItem); if ( event.nativeEvent.clientY < rowRect.top + rowRect.height / 2 ) { dropPosition = DropPosition.DROP_POSITION_BEFORE; checks(); } else { dropPosition = DropPosition.DROP_POSITION_AFTER; if ( event.nativeEvent.clientX > labelRect.left + CHILD_OFFSET && draggableAdapter.canDropInside(dropItem) && !draggableAdapter.isAncestorOfDragObject( dropItem ) && !( rowIndexAtCursor + 1 < $allRows.length && treeAdapter.isAncestor(nextItem!, dropItem) ) ) { dropPosition = DropPosition.DROP_POSITION_INSIDE; canDrop = true; } else if (dropItem === nextItemParent) { dropItem = nextItem!; dropPosition = DropPosition.DROP_POSITION_BEFORE; checks(); } else { let canDropToItem; while (true) { const $row = $treeDiv.find( `[data-object-id="${treeAdapter.getItemId( dropItem )}"]` ); if ($row.length > 0) { canDrop = false; checks(); if (canDrop) { canDropToItem = dropItem; const $label = $row.find("span"); const labelRect = $label .get(0)! .getBoundingClientRect(); if ( event.nativeEvent.clientX > labelRect.left ) { break; } } } const parentItem = treeAdapter.getItemParent(dropItem); if ( !parentItem || parentItem === nextItemParent ) { break; } dropItem = parentItem; } if (canDropToItem) { if ( treeAdapter.getItemParent( canDropToItem ) === nextItemParent ) { dropItem = nextItem!; dropPosition = DropPosition.DROP_POSITION_BEFORE; checks(); } else { dropItem = canDropToItem; canDrop = true; } } else { canDrop = false; } } } if (canDrop) { if ( dropItem !== draggableAdapter.dropItem || dropPosition !== this.dropPosition ) { if (dropPosition !== this.dropPosition) { this.dropPosition = dropPosition; } const $row = $treeDiv.find( `[data-object-id="${treeAdapter.getItemId( dropItem )}"]` ); const rowRect = $row[0].getBoundingClientRect(); const $label = $row.find("span"); const labelRect = $label .get(0)! .getBoundingClientRect(); this.dropMarkVerticalConnectionLineHeight = undefined; if ( dropPosition === DropPosition.DROP_POSITION_INSIDE ) { this.dropMarkLeft = labelRect.left - treeDivRect.left + CHILD_OFFSET; this.dropMarkTop = rowRect.bottom; this.dropMarkTop -= treeDivRect.top; this.dropMarkWidth = rowRect.right - labelRect.left - CHILD_OFFSET; } else { this.dropMarkLeft = labelRect.left - treeDivRect.left; if ( this.dropPosition === DropPosition.DROP_POSITION_BEFORE ) { this.dropMarkTop = rowRect.top; } else { if ( rowIndexAtCursor !== $allRows.index($row) ) { const $row2 = $allRows.eq(rowIndexAtCursor); const rowRect2 = $row2 .get(0)! .getBoundingClientRect(); this.dropMarkTop = rowRect2.bottom; this.dropMarkVerticalConnectionLineHeight = rowRect2.bottom - rowRect.bottom; } else { this.dropMarkTop = rowRect.bottom; } } this.dropMarkTop -= treeDivRect.top; this.dropMarkWidth = rowRect.right - labelRect.left; } } draggableAdapter.onDragOver(dropItem, event); return; } } } else { this.dropMarkTop = 0; this.dropMarkWidth = $treeDiv.width() || 100; this.dropPosition = DropPosition.DROP_POSITION_INSIDE; draggableAdapter.onDragOver( this.props.treeAdapter.rootItem, event ); return; } } this.dropPosition = undefined; draggableAdapter.onDragOver(undefined, event); } onDragLeave(event: any) { if (isFileData(event)) { return; } this.dropPosition = undefined; this.props.treeAdapter.draggableAdapter!.onDragLeave(event); } onDrop(event: React.DragEvent) { event.stopPropagation(); event.preventDefault(); if (isFileData(event)) { if (this.props.onFilesDrop) { const files: File[] = []; for (let i = 0; i < event.dataTransfer.items.length; i++) { const item = event.dataTransfer.items[i]; const file = item.getAsFile(); if (file) { files.push(file); } } this.props.onFilesDrop(files); } return; } if (this.props.treeAdapter.draggableAdapter!.dropItem) { let dropPosition = this.dropPosition; this.dropPosition = undefined; this.props.treeAdapter.draggableAdapter!.onDrop( dropPosition || DropPosition.DROP_POSITION_NONE, event ); } } onClick = (event: React.MouseEvent) => { //event.preventDefault(); //event.stopPropagation(); this.props.treeAdapter.selectItems([]); }; onMouseUp = (event: React.MouseEvent) => { if (event.button == 2) { //event.preventDefault(); //event.stopPropagation(); this.props.treeAdapter.selectItems([]); } }; onRowDragStart = (event: React.DragEvent) => { event.stopPropagation(); let item = this.props.treeAdapter.getItemFromId( $(event.target).attr("data-object-id")! ); this.props.treeAdapter.draggableAdapter!.onDragStart(item!, event); }; onRowDrag = (event: any) => { let item = this.props.treeAdapter.getItemFromId( $(event.target).attr("data-object-id")! ); this.props.treeAdapter.draggableAdapter!.onDrag(item!, event); }; onRowDragEnd = (event: any) => { this.props.treeAdapter.draggableAdapter!.onDragEnd(event); }; onRowClick = (event: React.MouseEvent) => { if (!(event.nativeEvent.target instanceof HTMLInputElement)) { event.preventDefault(); event.stopPropagation(); } const $rowDiv = $(event.target).closest( ".tree-row[data-object-id]" ); let item = this.props.treeAdapter.getItemFromId( $rowDiv.attr("data-object-id")! )!; if (event.shiftKey) { const $treeDiv = $rowDiv.parent(); const $selectedItems = $treeDiv.find(".tree-row.selected"); if ($selectedItems.length > 0) { let $rows = $treeDiv.find(".tree-row"); let iFirst = $rows.index( $selectedItems.first() as JQuery ); let iLast = $rows.index( $selectedItems.last() as JQuery ); let iThisItem = $rows.index( $treeDiv.find( `.tree-row[data-object-id="${this.props.treeAdapter.getItemId( item )}"]` ) as JQuery ); let iFrom; let iTo; if (iThisItem <= iFirst) { iFrom = iThisItem; iTo = iLast; } else if (iThisItem >= iLast) { iFrom = iFirst; iTo = iThisItem; } else if (iThisItem - iFirst > iLast - iThisItem) { iFrom = iFirst; iTo = iThisItem; } else { iFrom = iThisItem; iTo = iLast; } const items = []; for (let i = iFrom; i <= iTo; i++) { const id = $($rows.get(i)!).attr("data-object-id"); if (id) { const item = this.props.treeAdapter.getItemFromId(id); if (item) { items.push(item); } } } this.props.treeAdapter.selectItems(items); return; } else { this.props.treeAdapter.selectItem(item); } } else if (event.ctrlKey) { this.props.treeAdapter.toggleSelected(item); } else { this.props.treeAdapter.selectItem(item); this.props.treeAdapter.onClick(item); } }; onRowMouseUp = (event: React.MouseEvent) => { if (event.button === 2) { event.preventDefault(); event.stopPropagation(); const $rowDiv = $(event.target).closest( ".tree-row[data-object-id]" ); let item = this.props.treeAdapter.getItemFromId( $rowDiv.attr("data-object-id")! )!; if (!this.props.treeAdapter.isSelected(item)) { this.props.treeAdapter.selectItem(item); } } }; onRowDoubleClick = (event: any) => { event.preventDefault(); event.stopPropagation(); const $rowDiv = $(event.target).closest( ".tree-row[data-object-id]" ); let item = this.props.treeAdapter.getItemFromId( $rowDiv.attr("data-object-id")! )!; let row = this.props.treeAdapter.allRows.find( row => row.item == item )!; if (row.collapsable) { this.props.treeAdapter.selectItem(item); this.props.treeAdapter.collapsableAdapter!.toggleExpanded(item); } else { this.props.treeAdapter.onDoubleClick(item); } }; onRowToggleCollapse = (event: any) => { event.preventDefault(); event.stopPropagation(); const $rowDiv = $(event.target).closest( ".tree-row[data-object-id]" ); let item = this.props.treeAdapter.getItemFromId( $rowDiv.attr("data-object-id")! )!; this.props.treeAdapter.selectItem(item); this.props.treeAdapter.collapsableAdapter!.toggleExpanded(item); }; onContextMenu = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); this.props.treeAdapter.showSelectionContextMenu(); }; render() { const { treeAdapter, tabIndex, onFocus, onEditItem, renderItem } = this.props; const className = classNames("EezStudio_Tree", { "drag-source": treeAdapter.draggableAdapter && treeAdapter.draggableAdapter.isDragging }); return (
(this.treeDiv = ref!)} style={{ pointerEvents: treeAdapter.draggableAdapter && treeAdapter.draggableAdapter.isDragging ? "none" : "auto", position: "relative" }} > {this.allRows.map(row => ( ))} {this.dropPosition && ( )}
); } } ); //////////////////////////////////////////////////////////////////////////////// function isFileData(event: React.DragEvent) { if (!event.dataTransfer.items) { return false; } if (event.dataTransfer.items.length == 0) { return false; } for (let i = 0; i < event.dataTransfer.items.length; i++) { const item = event.dataTransfer.items[i]; if (item.kind !== "file") { return false; } } return true; }