import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import ResizeObserver from 'resize-observer-polyfill'; import { returnAlways, returnEmptyString } from '../../../ClientUtils'; import { Doc, StrListCast } from '../../../fields/Doc'; import { List } from '../../../fields/List'; import { ScriptField } from '../../../fields/ScriptField'; import { BoolCast, DocCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DragManager } from '../../util/DragManager'; import { ScriptManager } from '../../util/ScriptManager'; import { CompileError, CompileScript, ScriptParam } from '../../util/Scripting'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { ContextMenu } from '../ContextMenu'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { EditableView } from '../EditableView'; import { OverlayView } from '../OverlayView'; import { DocumentIconContainer } from './DocumentIcon'; import { FieldView, FieldViewProps } from './FieldView'; import './ScriptingBox.scss'; import * as ts from 'typescript'; import { FieldType } from '../../../fields/ObjectField'; const getCaretCoordinates = require('textarea-caret'); const ReactTextareaAutocomplete = require('@webscopeio/react-textarea-autocomplete').default; @observer export class ScriptingBox extends ViewBoxAnnotatableComponent() { private dropDisposer?: DragManager.DragDropDisposer; public static LayoutString(fieldStr: string) { return FieldView.LayoutString(ScriptingBox, fieldStr); } private _overlayDisposer?: () => void; private _caretPos = 0; @observable private _errorMessage: string = ''; @observable private _applied: boolean = false; @observable private _function: boolean = false; @observable private _spaced: boolean = false; @observable private _scriptKeys = ScriptingGlobals.getGlobals(); @observable private _scriptingDescriptions = ScriptingGlobals.getDescriptions(); @observable private _scriptingParams = ScriptingGlobals.getParameters(); @observable private _currWord: string = ''; @observable private _suggestions: string[] = []; @observable private _suggestionBoxX: number = 0; @observable private _suggestionBoxY: number = 0; @observable private _lastChar: string = ''; @observable private _suggestionRef = React.createRef(); @observable private _scriptTextRef = React.createRef(); @observable private _selection = 0; @observable private _paramSuggestion: boolean = false; @observable private _scriptSuggestedParams: JSX.Element | string = ''; @observable private _scriptParamsText = ''; constructor(props: FieldViewProps) { super(props); makeObservable(this); if (!this.compileParams.length) { const params = ScriptCast(this.dataDoc[this._props.fieldKey])?.script.options.params as { [key: string]: string }; if (params) { this.compileParams = Array.from(Object.keys(params)) .filter(p => !p.startsWith('_')) .map(key => key + ':' + params[key]); } } } // vars included in fields that store parameters types and names and the script itself @computed({ keepAlive: true }) get paramsNames() { return this.compileParams.map(p => p.split(':')[0].trim()); } @computed({ keepAlive: true }) get paramsTypes() { return this.compileParams.map(p => p.split(':')[1].trim()); } @computed({ keepAlive: true }) get rawScript() { return ScriptCast(this.dataDoc[this.fieldKey])?.script.originalScript ?? ''; } set rawScript(value) { this.dataDoc[this.fieldKey] = new ScriptField(undefined, undefined, value); } @computed({ keepAlive: true }) get functionName() { return StrCast(this.dataDoc[this.fieldKey + '-functionName'], ''); } set functionName(value) { this.dataDoc[this.fieldKey + '-functionName'] = value; } @computed({ keepAlive: true }) get functionDescription() { return StrCast(this.dataDoc[this.fieldKey + '-functionDescription'], ''); } set functionDescription(value) { this.dataDoc[this.fieldKey + '-functionDescription'] = value; } @computed({ keepAlive: true }) get compileParams() { return StrListCast(this.dataDoc[this.fieldKey + '-params']); } set compileParams(value) { this.dataDoc[this.fieldKey + '-params'] = new List(value); } onClickScriptDisable = returnAlways; @action componentDidMount() { this._props.setContentViewBox?.(this); this.rawText = this.rawScript; const resizeObserver = new ResizeObserver( action(() => { const area = document.querySelector('textarea'); if (area) { const caret = getCaretCoordinates(area, this._selection); this.resetSuggestionPos(caret); } }) ); resizeObserver.observe(document.getElementsByClassName('scriptingBox-outerDiv')[0]); } @action resetSuggestionPos(caret: { top: number; left: number; height: number }) { if (!this._suggestionRef.current || !this._scriptTextRef.current) return; const suggestionWidth = this._suggestionRef.current.offsetWidth; const scriptWidth = this._scriptTextRef.current.offsetWidth; const { top } = caret; const x = NumCast(this.layoutDoc.x); let { left } = caret; if (left + suggestionWidth > x + scriptWidth) { const diff = left + suggestionWidth - (x + scriptWidth); left -= diff; } this._suggestionBoxX = left; this._suggestionBoxY = top; } componentWillUnmount() { this._overlayDisposer?.(); } protected createDashEventsTarget = (ele: HTMLDivElement, dropFunc: (e: Event, de: DragManager.DropEvent) => void) => { // used for stacking and masonry view if (ele) { this.dropDisposer?.(); this.dropDisposer = DragManager.MakeDropTarget(ele, dropFunc, this.layoutDoc); } }; // only included in buttons, transforms scripting UI to a button @action onFinish = () => { this.layoutDoc.layout_fieldKey = 'layout'; }; // displays error message @action onError = (errors: ts.Diagnostic[] | string) => { this._errorMessage = typeof errors === 'string' ? errors : errors.map(entry => entry.toString()).join(' ') || ''; }; // checks if the script compiles using CompileScript method and inputting params @action onCompile = () => { const params: ScriptParam = {}; this.compileParams.forEach(p => { params[p.split(':')[0].trim()] = p.split(':')[1].trim(); }); const result = !this.rawText.trim() ? ({ compiled: false, errors: [] } as CompileError) : CompileScript(this.rawText, { editable: true, transformer: DocumentIconContainer.getTransformer(), params, typecheck: false, }); this.dataDoc[this.fieldKey] = result.compiled ? new ScriptField(result, undefined, this.rawText) : undefined; this.onError(result.compiled ? [] : result.errors); return result.compiled; }; // checks if the script compiles and then runs the script @action onRun = () => { if (this.onCompile()) { const bindings: { [name: string]: unknown } = {}; this.paramsNames.forEach(key => { bindings[key] = this.dataDoc[key]; }); // binds vars so user doesnt have to refer to everything as this. ScriptCast(this.dataDoc[this.fieldKey], null)?.script.run({ ...bindings, this: this.Document }, this.onError); } }; // checks if the script compiles and switches to applied UI @action onApply = () => { if (this.onCompile()) { this._applied = true; } }; @action onEdit = () => { this._errorMessage = ''; this._applied = false; this._function = false; }; @action onSave = () => { if (this.onCompile()) { this._function = true; } else { this._errorMessage = 'Can not save script, does not compile'; } }; @action onCreate = () => { this._errorMessage = ''; if (this.functionName.length === 0) { this._errorMessage = 'Must enter a function name'; return false; } if (this.functionName.indexOf(' ') > 0) { this._errorMessage = 'Name can not include spaces'; return false; } if (this.functionName.indexOf('.') > 0) { this._errorMessage = "Name can not include '.'"; return false; } this.dataDoc.name = this.functionName; this.dataDoc.description = this.functionDescription; // this.dataDoc.parameters = this.compileParams; this.dataDoc.script = this.rawScript; ScriptManager.Instance.addScript(this.dataDoc); this._scriptKeys = ScriptingGlobals.getGlobals(); this._scriptingDescriptions = ScriptingGlobals.getDescriptions(); this._scriptingParams = ScriptingGlobals.getParameters(); return undefined; }; // overlays document numbers (ex. d32) over all documents when clicked on onFocus = () => { this._overlayDisposer?.(); this._overlayDisposer = OverlayView.Instance.addElement(, { x: 0, y: 0 }); }; // sets field of the corresponding field key (param name) to be dropped document @action onDrop = (e: Event, de: DragManager.DropEvent, fieldKey: string) => { if (de.complete.docDragData) { de.complete.docDragData.droppedDocuments.forEach(doc => { this.dataDoc[fieldKey] = doc; }); e.stopPropagation(); return true; } return false; }; // deletes a param from all areas in which it is stored @action onDelete = (num: number) => { this.dataDoc[this.paramsNames[num]] = undefined; this.compileParams.splice(num, 1); return true; }; // sets field of the param name to the selected value in drop down box @action viewChanged = (e: React.ChangeEvent, name: string) => { const val = e.target.selectedOptions[0].value; this.dataDoc[name] = val[0] === 'S' ? val.substring(1) : val[0] === 'N' ? parseInt(val.substring(1)) : val.substring(1) === 'true'; }; // creates a copy of the script document onCopy = () => { const copy = Doc.MakeCopy(this.Document, true); copy.x = NumCast(this.Document.x) + NumCast(this.dataDoc._width); this._props.addDocument?.(copy); }; // adds option to create a copy to the context menu specificContextMenu = (): void => { const existingOptions = ContextMenu.Instance.findByDescription('Options...'); const options = existingOptions?.subitems ?? []; options.push({ description: 'Create a Copy', event: this.onCopy, icon: 'copy' }); !existingOptions && ContextMenu.Instance.addItem({ description: 'Options...', subitems: options, icon: 'hand-point-right' }); }; renderFunctionInputs() { const descriptionInput = (