zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts (545 lines of code) (raw):

/* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, QueryList, SimpleChanges, ViewChild, ViewChildren } from '@angular/core'; import { merge, Observable, Subject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { NzModalService } from 'ng-zorro-antd/modal'; import { ParagraphBase } from '@zeppelin/core'; import { InterpreterBindingItem, Note, ParagraphConfigResult, ParagraphItem } from '@zeppelin/sdk'; import { HeliumService, MessageService, NgZService, NoteStatusService, NoteVarShareService, ParagraphActions, ShortcutsMap, ShortcutService } from '@zeppelin/services'; import { SpellResult } from '@zeppelin/spell/spell-result'; import { NzResizeEvent } from 'ng-zorro-antd/resizable'; import { NotebookParagraphResultComponent } from '../../share/result/result.component'; import { NotebookParagraphCodeEditorComponent } from './code-editor/code-editor.component'; type Mode = 'edit' | 'command'; @Component({ selector: 'zeppelin-notebook-paragraph', templateUrl: './paragraph.component.html', styleUrls: ['./paragraph.component.less'], host: { tabindex: '-1', '(focusin)': 'onFocus()' }, changeDetection: ChangeDetectionStrategy.OnPush }) export class NotebookParagraphComponent extends ParagraphBase implements OnInit, OnChanges, OnDestroy, AfterViewInit { @ViewChild(NotebookParagraphCodeEditorComponent, { static: false }) notebookParagraphCodeEditorComponent: NotebookParagraphCodeEditorComponent; @ViewChildren(NotebookParagraphResultComponent) notebookParagraphResultComponents: QueryList< NotebookParagraphResultComponent >; @Input() paragraph: ParagraphItem; @Input() note: Note['note']; @Input() looknfeel: string; @Input() revisionView: boolean; @Input() select: boolean = false; @Input() scrolled: boolean = false; @Input() index: number = -1; @Input() viewOnly: boolean; @Input() last: boolean; @Input() collaborativeMode = false; @Input() first: boolean; @Input() interpreterBindings: InterpreterBindingItem[] = []; @Output() readonly saveNoteTimer = new EventEmitter(); @Output() readonly triggerSaveParagraph = new EventEmitter<string>(); @Output() readonly selected = new EventEmitter<string>(); @Output() readonly selectAtIndex = new EventEmitter<number>(); private destroy$ = new Subject(); private mode: Mode = 'command'; waitConfirmFromEdit = false; switchMode(mode: Mode): void { if (mode === this.mode) { return; } this.mode = mode; if (mode === 'edit') { this.focusEditor(); } else { this.blurEditor(); } } textChanged(text: string) { this.dirtyText = text; this.paragraph.text = text; if (this.dirtyText !== this.originalText) { if (this.collaborativeMode) { this.sendPatch(); } else { this.startSaveTimer(); } } } sendPatch() { this.originalText = this.originalText ? this.originalText : ''; const patch = this.diffMatchPatch.patch_make(this.originalText, this.dirtyText).toString(); this.originalText = this.dirtyText; this.messageService.patchParagraph(this.paragraph.id, this.note.id, patch); } startSaveTimer() { this.saveNoteTimer.emit(); } onFocus() { this.selected.emit(this.paragraph.id); } focusEditor() { this.paragraph.focus = true; this.saveParagraph(); this.cdr.markForCheck(); } blurEditor() { this.paragraph.focus = false; (this.host.nativeElement as HTMLElement).focus(); this.saveParagraph(); this.cdr.markForCheck(); } onEditorFocus() { this.switchMode('edit'); } onEditorBlur() { // Ignore events triggered by open the confirm box in edit mode if (!this.waitConfirmFromEdit) { this.switchMode('command'); } } saveParagraph() { const dirtyText = this.paragraph.text; if (dirtyText === undefined || dirtyText === this.originalText) { return; } this.commitParagraph(); this.originalText = dirtyText; this.dirtyText = undefined; this.cdr.markForCheck(); } removeParagraph() { if (!this.isEntireNoteRunning) { if (this.note.paragraphs.length === 1) { this.nzModalService.warning({ nzTitle: `Warning`, nzContent: `All the paragraphs can't be deleted` }); } else { this.nzModalService.confirm({ nzTitle: 'Delete Paragraph', nzContent: 'Do you want to delete this paragraph?', nzOnOk: () => { this.messageService.paragraphRemove(this.paragraph.id); this.cdr.markForCheck(); // TODO(hsuanxyz) moveFocusToNextParagraph } }); } } } runAllAbove() { const index = this.note.paragraphs.findIndex(p => p.id === this.paragraph.id); const toRunParagraphs = this.note.paragraphs.filter((p, i) => i < index); const paragraphs = toRunParagraphs.map(p => { return { id: p.id, title: p.title, paragraph: p.text, config: p.config, params: p.settings.params }; }); this.nzModalService.confirm({ nzTitle: 'Run all above?', nzContent: 'Are you sure to run all above paragraphs?', nzOnOk: () => { this.messageService.runAllParagraphs(this.note.id, paragraphs); } }); // TODO(hsuanxyz): save cursor } doubleClickParagraph() { if (this.paragraph.config.editorSetting.editOnDblClick && this.revisionView !== true) { this.paragraph.config.editorHide = false; this.paragraph.config.tableHide = true; // TODO(hsuanxyz): focus editor } } runAllBelowAndCurrent() { const index = this.note.paragraphs.findIndex(p => p.id === this.paragraph.id); const toRunParagraphs = this.note.paragraphs.filter((p, i) => i >= index); const paragraphs = toRunParagraphs.map(p => { return { id: p.id, title: p.title, paragraph: p.text, config: p.config, params: p.settings.params }; }); this.nzModalService .confirm({ nzTitle: 'Run current and all below?', nzContent: 'Are you sure to run current and all below?', nzOnOk: () => { this.messageService.runAllParagraphs(this.note.id, paragraphs); } }) .afterClose.pipe(takeUntil(this.destroy$)) .subscribe(() => { this.waitConfirmFromEdit = false; }); // TODO(hsuanxyz): save cursor } cloneParagraph(position: string = 'below', newText?: string) { let newIndex = -1; for (let i = 0; i < this.note.paragraphs.length; i++) { if (this.note.paragraphs[i].id === this.paragraph.id) { // determine position of where to add new paragraph; default is below if (position === 'above') { newIndex = i; } else { newIndex = i + 1; } break; } } if (newIndex < 0 || newIndex > this.note.paragraphs.length) { return; } const config = this.paragraph.config; config.editorHide = false; this.messageService.copyParagraph( newIndex, this.paragraph.title, newText || this.paragraph.text, config, this.paragraph.settings.params ); } runParagraphAfter(text: string) { this.originalText = text; this.dirtyText = undefined; if (this.paragraph.config.editorSetting.editOnDblClick) { this.paragraph.config.editorHide = true; this.paragraph.config.tableHide = false; this.commitParagraph(); } else if (this.editorSetting.isOutputHidden && !this.paragraph.config.editorSetting.editOnDblClick) { // %md/%angular repl make output to be hidden by default after running // so should open output if repl changed from %md/%angular to another this.paragraph.config.editorHide = false; this.paragraph.config.tableHide = false; this.commitParagraph(); } this.editorSetting.isOutputHidden = this.paragraph.config.editorSetting.editOnDblClick; } runParagraph(paragraphText?: string, propagated: boolean = false) { const text = paragraphText || this.paragraph.text; if (text && !this.isParagraphRunning) { const magic = SpellResult.extractMagic(text); if (this.heliumService.getSpellByMagic(magic)) { this.runParagraphUsingSpell(text, magic, propagated); this.runParagraphAfter(text); } else { this.runParagraphUsingBackendInterpreter(text); this.runParagraphAfter(text); } } } insertParagraph(position: string) { if (this.revisionView === true) { return; } let newIndex = -1; for (let i = 0; i < this.note.paragraphs.length; i++) { if (this.note.paragraphs[i].id === this.paragraph.id) { // determine position of where to add new paragraph; default is below if (position === 'above') { newIndex = i; } else { newIndex = i + 1; } break; } } if (newIndex < 0 || newIndex > this.note.paragraphs.length) { return; } this.messageService.insertParagraph(newIndex); this.cdr.markForCheck(); } setTitle(title: string) { this.paragraph.title = title; this.commitParagraph(); } commitParagraph() { const { id, title, text, config, settings: { params } } = this.paragraph; this.messageService.commitParagraph(id, title, text, config, params, this.note.id); this.cdr.markForCheck(); } moveUpParagraph() { const newIndex = this.note.paragraphs.findIndex(p => p.id === this.paragraph.id) - 1; if (newIndex < 0 || newIndex >= this.note.paragraphs.length) { return; } // save dirtyText of moving paragraphs. const prevParagraph = this.note.paragraphs[newIndex]; // TODO(hsuanxyz): save pre paragraph? this.saveParagraph(); this.triggerSaveParagraph.emit(prevParagraph.id); this.messageService.moveParagraph(this.paragraph.id, newIndex); } moveDownParagraph() { const newIndex = this.note.paragraphs.findIndex(p => p.id === this.paragraph.id) + 1; if (newIndex < 0 || newIndex >= this.note.paragraphs.length) { return; } // save dirtyText of moving paragraphs. const nextParagraph = this.note.paragraphs[newIndex]; // TODO(hsuanxyz): save pre paragraph? this.saveParagraph(); this.triggerSaveParagraph.emit(nextParagraph.id); this.messageService.moveParagraph(this.paragraph.id, newIndex); } changeColWidth(needCommit: boolean, updateResult = true) { if (needCommit) { this.commitParagraph(); } if (this.notebookParagraphCodeEditorComponent) { this.notebookParagraphCodeEditorComponent.layout(); } if (updateResult) { this.notebookParagraphResultComponents.forEach(comp => { comp.setGraphConfig(); }); } } onSizeChange(resize: NzResizeEvent) { this.paragraph.config.colWidth = resize.col; this.changeColWidth(true, false); this.cdr.markForCheck(); } onConfigChange(configResult: ParagraphConfigResult, index: number) { this.paragraph.config.results[index] = configResult; this.commitParagraph(); } setEditorHide(editorHide: boolean) { this.paragraph.config.editorHide = editorHide; this.cdr.markForCheck(); } setTableHide(tableHide: boolean) { this.paragraph.config.tableHide = tableHide; this.cdr.markForCheck(); } openSingleParagraph(paragraphId: string): void { const noteId = this.note.id; const redirectToUrl = `${location.protocol}//${location.host}${location.pathname}#/notebook/${noteId}/paragraph/${paragraphId}`; window.open(redirectToUrl); } trackByIndexFn(index: number) { return index; } constructor( noteStatusService: NoteStatusService, cdr: ChangeDetectorRef, ngZService: NgZService, private heliumService: HeliumService, public messageService: MessageService, private nzModalService: NzModalService, private noteVarShareService: NoteVarShareService, private shortcutService: ShortcutService, private host: ElementRef ) { super(messageService, noteStatusService, ngZService, cdr); } ngOnInit() { const shortcutService = this.shortcutService.forkByElement(this.host.nativeElement); const observables: Array<Observable<{ action: ParagraphActions; event: KeyboardEvent; }>> = []; Object.entries(ShortcutsMap).forEach(([action, keys]) => { const keysArr: string[] = Array.isArray(keys) ? keys : [keys]; keysArr.forEach(key => { observables.push( shortcutService .bindShortcut({ keybindings: key }) .pipe( takeUntil(this.destroy$), map(({ event }) => { return { event, action: action as ParagraphActions }; }) ) ); }); }); merge<{ action: ParagraphActions; event: KeyboardEvent; }>(...observables) .pipe(takeUntil(this.destroy$)) .subscribe(({ action, event }) => { if (this.mode === 'command') { switch (action) { case ParagraphActions.InsertAbove: this.insertParagraph('above'); break; case ParagraphActions.InsertBelow: this.insertParagraph('below'); break; case ParagraphActions.SwitchEditorShow: this.setEditorHide(!this.paragraph.config.editorHide); this.commitParagraph(); break; case ParagraphActions.SwitchOutputShow: this.setTableHide(!this.paragraph.config.tableHide); this.commitParagraph(); break; case ParagraphActions.SwitchTitleShow: this.paragraph.config.title = !this.paragraph.config.title; this.commitParagraph(); break; case ParagraphActions.SwitchLineNumber: this.paragraph.config.lineNumbers = !this.paragraph.config.lineNumbers; this.commitParagraph(); break; case ParagraphActions.MoveToUp: event.preventDefault(); this.moveUpParagraph(); break; case ParagraphActions.MoveToDown: event.preventDefault(); this.moveDownParagraph(); break; case ParagraphActions.SwitchEnable: this.paragraph.config.enabled = !this.paragraph.config.enabled; this.commitParagraph(); break; case ParagraphActions.ReduceWidth: this.paragraph.config.colWidth = Math.max(1, this.paragraph.config.colWidth - 1); this.cdr.markForCheck(); this.changeColWidth(true); break; case ParagraphActions.IncreaseWidth: this.paragraph.config.colWidth = Math.min(12, this.paragraph.config.colWidth + 1); this.cdr.markForCheck(); this.changeColWidth(true); break; case ParagraphActions.Delete: this.removeParagraph(); break; case ParagraphActions.SelectAbove: event.preventDefault(); this.selectAtIndex.emit(this.index - 1); break; case ParagraphActions.SelectBelow: event.preventDefault(); this.selectAtIndex.emit(this.index + 1); break; default: break; } } switch (action) { case ParagraphActions.Link: this.openSingleParagraph(this.paragraph.id); break; case ParagraphActions.EditMode: if (this.mode === 'command') { event.preventDefault(); } if (!this.paragraph.config.editorHide) { this.switchMode('edit'); } break; case ParagraphActions.Run: event.preventDefault(); this.runParagraph(); break; case ParagraphActions.RunBelow: this.waitConfirmFromEdit = true; this.runAllBelowAndCurrent(); break; case ParagraphActions.Cancel: event.preventDefault(); this.cancelParagraph(); break; default: break; } }); this.setResults(); this.originalText = this.paragraph.text; this.isEntireNoteRunning = this.noteStatusService.isEntireNoteRunning(this.note); this.isParagraphRunning = this.noteStatusService.isParagraphRunning(this.paragraph); this.noteVarShareService.set(this.paragraph.id + '_paragraphScope', this); this.initializeDefault(this.paragraph.config); this.ngZService .runParagraphAction() .pipe(takeUntil(this.destroy$)) .subscribe(id => { if (id === this.paragraph.id) { this.runParagraph(); } }); this.ngZService .contextChanged() .pipe(takeUntil(this.destroy$)) .subscribe(change => { if (change.paragraphId === this.paragraph.id && change.emit) { if (change.set) { this.messageService.angularObjectClientBind(this.note.id, change.key, change.value, change.paragraphId); } else { this.messageService.angularObjectClientUnbind(this.note.id, change.key, change.paragraphId); } } }); } scrollIfNeeded(): void { if (this.scrolled && this.host.nativeElement) { setTimeout(() => { this.host.nativeElement.scrollIntoView(); }); } } ngOnChanges(changes: SimpleChanges): void { const { index, select, scrolled } = changes; if ( (index && index.currentValue !== index.previousValue && this.select) || (select && select.currentValue === true && select.previousValue !== true) ) { setTimeout(() => { if (this.mode === 'command' && this.host.nativeElement) { (this.host.nativeElement as HTMLElement).focus(); } }); } if (scrolled) { this.scrollIfNeeded(); } } getElement(): HTMLElement { return this.host && this.host.nativeElement; } ngAfterViewInit(): void { this.scrollIfNeeded(); } ngOnDestroy(): void { super.ngOnDestroy(); } }