// ***************************************************************************** // Copyright (C) 2023 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import * as React from '@theia/core/shared/react'; import { CellEditType, CellKind, NotebookCellsChangeType } from '../../common'; import { NotebookCellModel } from '../view-model/notebook-cell-model'; import { NotebookModel } from '../view-model/notebook-model'; import { NotebookCellToolbarFactory } from './notebook-cell-toolbar-factory'; import { animationFrame, onDomEvent } from '@theia/core/lib/browser'; import { CommandMenu, CommandRegistry, DisposableCollection, MenuModelRegistry, nls } from '@theia/core'; import { NotebookCommands, NotebookMenus } from '../contributions/notebook-actions-contribution'; import { NotebookCellActionContribution } from '../contributions/notebook-cell-actions-contribution'; import { NotebookContextManager } from '../service/notebook-context-manager'; import { NotebookViewModel } from '../view-model/notebook-view-model'; export interface CellRenderer { render(notebookData: NotebookModel, cell: NotebookCellModel, index: number): React.ReactNode renderSidebar(notebookModel: NotebookModel, cell: NotebookCellModel): React.ReactNode renderDragImage(cell: NotebookCellModel): HTMLElement } export function observeCellHeight(ref: HTMLDivElement | null, cell: NotebookCellModel): void { if (ref) { cell.cellHeight = ref?.getBoundingClientRect().height ?? 0; new ResizeObserver(entries => cell.cellHeight = ref?.getBoundingClientRect().height ?? 0 ).observe(ref); } } interface CellListProps { renderers: Map; notebookModel: NotebookModel; notebookViewModel: NotebookViewModel; notebookContext: NotebookContextManager; toolbarRenderer: NotebookCellToolbarFactory; commandRegistry: CommandRegistry; menuRegistry: MenuModelRegistry; } interface NotebookCellListState { selectedCell?: NotebookCellModel; scrollIntoView: boolean; dragOverIndicator: { cell: NotebookCellModel, position: 'top' | 'bottom' } | undefined; } export class NotebookCellListView extends React.Component { protected toDispose = new DisposableCollection(); protected static dragGhost: HTMLElement | undefined; protected cellListRef: React.RefObject = React.createRef(); constructor(props: CellListProps) { super(props); this.state = { selectedCell: props.notebookViewModel.selectedCell, dragOverIndicator: undefined, scrollIntoView: true }; this.toDispose.push(props.notebookModel.onDidAddOrRemoveCell(e => { if (e.newCellIds && e.newCellIds.length > 0) { this.setState({ ...this.state, selectedCell: props.notebookViewModel.selectedCell, scrollIntoView: true }); } else { this.setState({ ...this.state, selectedCell: props.notebookViewModel.selectedCell, scrollIntoView: false }); } })); this.toDispose.push(props.notebookModel.onDidChangeContent(events => { if (events.some(e => e.kind === NotebookCellsChangeType.Move)) { // When a cell has been moved, we need to rerender the whole component this.forceUpdate(); } })); this.toDispose.push(props.notebookViewModel.onDidChangeSelectedCell(e => { this.setState({ ...this.state, selectedCell: e.cell, scrollIntoView: e.scrollIntoView }); })); this.toDispose.push(onDomEvent(document, 'focusin', () => { animationFrame().then(() => { if (!this.cellListRef.current) { return; } let hasCellFocus = false; let hasFocus = false; if (this.cellListRef.current.contains(document.activeElement)) { if (this.props.notebookViewModel.selectedCell) { hasCellFocus = true; } hasFocus = true; } this.props.notebookContext.changeCellFocus(hasCellFocus); this.props.notebookContext.changeCellListFocus(hasFocus); }); })); } override componentWillUnmount(): void { this.toDispose.dispose(); } override render(): React.ReactNode { return
    this.onDragStart(e)}> {this.props.notebookModel.getVisibleCells() .map((cell, index) => { const cellViewModel = this.props.notebookViewModel.cellViewModels.get(cell.handle); return this.isEnabled()} onAddNewCell={handler => this.onAddNewCell(handler, index)} onDrop={e => this.onDrop(e, index)} onDragOver={e => this.onDragOver(e, cell, 'top')} />
  • { NotebookCellListView.dragGhost?.remove(); this.setState({ ...this.state, dragOverIndicator: undefined }); }} onDragOver={e => this.onDragOver(e, cell)} onDrop={e => this.onDrop(e, index)} draggable={true} tabIndex={-1} data-cell-handle={cell.handle} ref={ref => { if (ref && cell === this.state.selectedCell && this.state.scrollIntoView) { ref.scrollIntoView({ block: 'nearest' }); if (cell.cellKind === CellKind.Markup && !cellViewModel?.editing) { ref.focus(); } } }} onClick={e => { this.setState({ ...this.state, selectedCell: cell }); this.props.notebookViewModel.setSelectedCell(cell, false); }} >
    {this.renderCellSidebar(cell)}
    {this.renderCellContent(cell, index)}
    {this.state.selectedCell === cell && this.props.toolbarRenderer.renderCellToolbar(NotebookCellActionContribution.ACTION_MENU, cell, { contextMenuArgs: () => [cell], commandArgs: () => [this.props.notebookModel, cell] }) }
  • ; }) } this.isEnabled()} onAddNewCell={handler => this.onAddNewCell(handler, this.props.notebookModel.cells.length)} onDrop={e => this.onDrop(e, this.props.notebookModel.cells.length - 1)} onDragOver={e => this.onDragOver(e, this.props.notebookModel.cells[this.props.notebookModel.cells.length - 1], 'bottom')} />
; } renderCellContent(cell: NotebookCellModel, index: number): React.ReactNode { const renderer = this.props.renderers.get(cell.cellKind); if (!renderer) { throw new Error(`No renderer found for cell type ${cell.cellKind}`); } return renderer.render(this.props.notebookModel, cell, index); } renderCellSidebar(cell: NotebookCellModel): React.ReactNode { const renderer = this.props.renderers.get(cell.cellKind); if (!renderer) { throw new Error(`No renderer found for cell type ${cell.cellKind}`); } return renderer.renderSidebar(this.props.notebookModel, cell); } protected onDragStart(event: React.DragEvent): void { event.stopPropagation(); if (!this.isEnabled()) { event.preventDefault(); return; } const cellHandle = (event.target as HTMLLIElement).getAttribute('data-cell-handle'); if (!cellHandle) { throw new Error('Cell handle not found in element for cell drag event'); } const index = this.props.notebookModel.getCellIndexByHandle(parseInt(cellHandle)); const cell = this.props.notebookModel.cells[index]; NotebookCellListView.dragGhost = document.createElement('div'); NotebookCellListView.dragGhost.classList.add('theia-notebook-drag-ghost-image'); NotebookCellListView.dragGhost.appendChild(this.props.renderers.get(cell.cellKind)?.renderDragImage(cell) ?? document.createElement('div')); document.body.appendChild(NotebookCellListView.dragGhost); event.dataTransfer.setDragImage(NotebookCellListView.dragGhost, -10, 0); event.dataTransfer.setData('text/theia-notebook-cell-index', index.toString()); event.dataTransfer.setData('text/plain', this.props.notebookModel.cells[index].source); } protected onDragOver(event: React.DragEvent, cell: NotebookCellModel, position?: 'top' | 'bottom'): void { if (!this.isEnabled()) { return; } event.preventDefault(); event.stopPropagation(); // show indicator this.setState({ ...this.state, dragOverIndicator: { cell, position: position ?? event.nativeEvent.offsetY < event.currentTarget.clientHeight / 2 ? 'top' : 'bottom' } }); } protected isEnabled(): boolean { return !Boolean(this.props.notebookModel.readOnly); } protected onDrop(event: React.DragEvent, dropElementIndex: number): void { if (!this.isEnabled()) { this.setState({ dragOverIndicator: undefined }); return; } const index = parseInt(event.dataTransfer.getData('text/theia-notebook-cell-index')); const isTargetBelow = index < dropElementIndex; let newIdx = this.state.dragOverIndicator?.position === 'top' ? dropElementIndex : dropElementIndex + 1; newIdx = isTargetBelow ? newIdx - 1 : newIdx; if (index !== undefined && index !== dropElementIndex) { this.props.notebookModel.applyEdits([{ editType: CellEditType.Move, length: 1, index, newIdx }], true); } this.setState({ ...this.state, dragOverIndicator: undefined }); } protected onAddNewCell(handler: (...args: unknown[]) => void, index: number): void { if (this.isEnabled()) { this.props.commandRegistry.executeCommand(NotebookCommands.CHANGE_SELECTED_CELL.id, index - 1); handler( this.props.notebookModel, index ); } } protected shouldRenderDragOverIndicator(cell: NotebookCellModel, position: 'top' | 'bottom'): boolean { return this.isEnabled() && this.state.dragOverIndicator !== undefined && this.state.dragOverIndicator.cell === cell && this.state.dragOverIndicator.position === position; } } export interface NotebookCellDividerProps { isVisible: () => boolean; onAddNewCell: (createCommand: (...args: unknown[]) => void) => void; onDrop: (event: React.DragEvent) => void; onDragOver: (event: React.DragEvent) => void; menuRegistry: MenuModelRegistry; } export function NotebookCellDivider({ isVisible, onAddNewCell, onDrop, onDragOver, menuRegistry }: NotebookCellDividerProps): React.JSX.Element { const [hover, setHover] = React.useState(false); const menuPath = NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP; // we contribute into this menu, so it will exist const menuItems: CommandMenu[] = menuRegistry.getMenu(menuPath)!.children.filter(item => CommandMenu.is(item)).map(item => item as CommandMenu); const renderItem = (item: CommandMenu): React.ReactNode => { const execute = (...args: unknown[]) => { if (CommandMenu.is(item)) { item.run([...menuPath, item.id], ...args); } }; return ; }; return
  • setHover(true)} onMouseLeave={() => setHover(false)} onDrop={onDrop} onDragOver={onDragOver}> {hover && isVisible() &&
    {menuItems.map((item: CommandMenu) => renderItem(item))}
    }
  • ; } function CellDropIndicator(props: { visible: boolean }): React.JSX.Element { return
    ; }