import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; import { ObservableReactComponent } from "../../ObservableReactComponent"; import { observer } from "mobx-react"; import { OverlayView } from "../../OverlayView"; import { DocumentIconContainer } from "../../nodes/DocumentIcon"; import React, { FormEvent } from "react"; import { FieldView, FieldViewProps } from "../../nodes/FieldView"; import { ObjectField } from "../../../../fields/ObjectField"; import { Doc } from "../../../../fields/Doc"; import { DocumentView } from "../../nodes/DocumentView"; export interface SchemaCellFieldProps { contents: any; fieldContents?: FieldViewProps; editing?: boolean; oneLine?: boolean; Document: Doc; fieldKey: string; refSelectModeInfo: {enabled: boolean, currEditing: SchemaCellField | undefined}; highlightCells?: (text: string) => void; GetValue(): string | undefined; SetValue(value: string, shiftDown?: boolean, enterKey?: boolean): boolean; getCells: (text: string) => HTMLDivElement[] | []; } @observer export class SchemaCellField extends ObservableReactComponent { private _disposers: { [name: string]: IReactionDisposer } = {}; private _inputref: HTMLDivElement | null = null; private _unrenderedContent: string = ''; _overlayDisposer?: () => void; @observable _editing: boolean = false; @observable _displayedContent = ''; @observable _inCellSelectMode: boolean = false; @observable _dependencyMessageShown: boolean = false; constructor(props: SchemaCellFieldProps) { super(props); makeObservable(this); setTimeout(() => { this._unrenderedContent = this._props.GetValue() ?? ''; this.setContent(this._unrenderedContent); }); //must be moved to end of batch or else other docs aren't loaded, so render as d-1 in function } get docIndex(){return DocumentView.getDocViewIndex(this._props.Document);} // prettier-ignore get selfRefPattern() {return `d${this.docIndex}.${this._props.fieldKey}`}; @computed get lastCharBeforeCursor(){ const pos = this.cursorPosition; const content = this._unrenderedContent; const text = this._unrenderedContent.substring(0, pos ?? content.length); for (let i = text.length - 1; i > 0; --i) { if (text.charCodeAt(i) !== 160 && text.charCodeAt(i) !== 32) { return text[i]; } } return null; } @computed get refSelectConditionMet() { const char = this.lastCharBeforeCursor; return char === '+' || char === '*' || char === '/' || char === '%' || char === '='; } componentDidMount(): void { this._unrenderedContent = this._props.GetValue() ?? ''; this.setContent(this._unrenderedContent, true); this._disposers.editing = reaction( () => this._editing, editing => { if (editing) { this.setupRefSelect(this.refSelectConditionMet); setTimeout(() => { if (this._inputref?.innerText.startsWith('=') || this._inputref?.innerText.startsWith(':=')) { this._overlayDisposer?.(); this._overlayDisposer = OverlayView.Instance.addElement(, { x: 0, y: 0 }); this._props.highlightCells?.(this._unrenderedContent); this.setContent(this._unrenderedContent); setTimeout(() => this.setCursorPosition(this._unrenderedContent.length)); } }); } else { this._overlayDisposer?.(); this._overlayDisposer = undefined; this._props.highlightCells?.(''); this.setupRefSelect(false); } }, { fireImmediately: true } ); this._disposers.fieldUpdate = reaction( () => this._props.GetValue(), fieldVal => { this._unrenderedContent = fieldVal ?? ''; this.setContent(this._unrenderedContent); } ) } componentDidUpdate(prevProps: Readonly) { super.componentDidUpdate(prevProps); if (this._editing && this._props.editing === false) { this.finalizeEdit(false, true, false); } else runInAction(() => { if (this._props.editing !== undefined) this._editing = this._props.editing; }); } componentWillUnmount(): void { this._overlayDisposer?.(); Object.values(this._disposers).forEach(disposer => disposer?.()); this.finalizeEdit(false, true, false); } generateSpan = (text: string, cell: HTMLDivElement | undefined) => { const selfRef = text === this.selfRefPattern; return `${text}`; } makeSpans = (content: string) => { let chunkedText = content; const pattern = /(this|d(\d+))\.(\w+)/g; const matches: string[] = []; let match: RegExpExecArray | null; const cells: Map = new Map(); while ((match = pattern.exec(content)) !== null) { const cell = this._props.getCells(match[0]); if (cell.length) { matches.push(match[0]); cells.set(match[0], cell[0]) } } let matchNum = 0; matches.forEach((match: string) => { chunkedText = chunkedText.replace(match, this.generateSpan(match, cells.get(match))); ++matchNum; }) return chunkedText; } @action setContent = (content: string, restoreCursorPos?: boolean) => { const pos = this.cursorPosition; this._displayedContent = this.makeSpans(content); restoreCursorPos && setTimeout(() => this.setCursorPosition(pos)); } @action appendText = (text: string, atCursorPos?: boolean) => { const content = this._unrenderedContent; const cursorPos = this.cursorPosition; const robustPos = cursorPos ?? content.length; const newText = atCursorPos ? content.slice(0, robustPos) + text + content.slice(cursorPos ?? content.length) : this._unrenderedContent.concat(text); this.onChange(undefined, newText); setTimeout(() => this.setCursorPosition(robustPos + text.length)); } @action setIsFocused = (value: boolean) => { const wasFocused = this._editing; this._editing = value; return wasFocused !== this._editing; }; get cursorPosition() { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || !this._inputref) return null; const range = selection.getRangeAt(0); const adjRange = range.cloneRange(); adjRange.selectNodeContents(this._inputref); adjRange.setEnd(range.startContainer, range.startOffset); return adjRange.toString().length; } setCursorPosition = (position: number | null) => { const selection = window.getSelection(); if (!selection || position === null || !this._inputref) return; const range = document.createRange(); range.setStart(this._inputref, 0); range.collapse(true); let currentPos = 0; const setRange = (nodes: NodeList) => { for (let i = 0; i < nodes.length; ++i) { const node = nodes[i]; if (node.nodeType === Node.TEXT_NODE) { if (!node.textContent) return; const nextPos = currentPos + node.textContent.length; if (position <= nextPos) { range.setStart(node, position - currentPos); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return true; } currentPos = nextPos; } else if ((node.nodeType === Node.ELEMENT_NODE) && (setRange(node.childNodes))) return true; } return false; } setRange(this._inputref.childNodes); }; shouldUpdate = (prevVal: string, currVal: string) => { if (this._props.getCells(currVal).length !== this._props.getCells(prevVal).length) return true; //if (contains self-ref pattern) }; onChange = (e: FormEvent | undefined, newText?: string) => { const prevVal = this._unrenderedContent; const targVal = newText ?? e!.currentTarget.innerText; // TODO: bang if (!(targVal.startsWith(':=') || targVal.startsWith('='))) { this._overlayDisposer?.(); this._overlayDisposer = undefined; } else if (!this._overlayDisposer) { this._overlayDisposer = OverlayView.Instance.addElement(, { x: 0, y: 0 }); } this._unrenderedContent = targVal; this._props.highlightCells?.(targVal); if (this.shouldUpdate(prevVal, targVal)) {this.setContent(targVal, true)}; this.setupRefSelect(this.refSelectConditionMet); }; setupRefSelect = (enabled: boolean) => { const properties = this._props.refSelectModeInfo; properties.enabled = enabled; properties.currEditing = enabled ? this : undefined; } @action onKeyDown = (e: React.KeyboardEvent) => { if (e.nativeEvent.defaultPrevented) return; // hack .. DashFieldView grabs native events, but react ignores stoppedPropagation and preventDefault, so we need to check it here switch (e.key) { case 'Tab': e.stopPropagation(); this.finalizeEdit(e.shiftKey, false, false); break; case 'Backspace': e.stopPropagation(); break; case 'Enter': e.stopPropagation(); if (!e.ctrlKey) { this.finalizeEdit(e.shiftKey, false, true); } break; case 'Escape': e.stopPropagation(); this._editing = false; break; case 'ArrowUp': case 'ArrowDown': case 'ArrowLeft': case 'ArrowRight': // prettier-ignore e.stopPropagation(); setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0) break; case ' ': e.stopPropagation(); let cursorPos = 0; if (this.cursorPosition !== null) cursorPos = this.cursorPosition + 1; setTimeout(() => { this.setContent(this._unrenderedContent); setTimeout(() => this.setCursorPosition(cursorPos)); } , 0); break; case 'u': // for some reason 'u' otherwise exits the editor e.stopPropagation(); break; case 'Shift': case 'Alt': case 'Meta': case 'Control': case ':': // prettier-ignore break; // eslint-disable-next-line no-fallthrough default: break; } }; @action onClick = (e?: React.MouseEvent) => { if (this._props.editing !== false) { e?.nativeEvent.stopPropagation(); this._editing = true; } }; @action finalizeEdit(shiftDown: boolean, lostFocus: boolean, enterKey: boolean) { if (this._unrenderedContent.replace(this.selfRefPattern, '') !== this._unrenderedContent) { this._dependencyMessageShown ? this._dependencyMessageShown = false : alert("Circular dependency detected. Please update the field.") this._dependencyMessageShown = true; return; } if (this._props.SetValue(this._unrenderedContent, shiftDown, enterKey)) { this._editing = false; } else { this._editing = false; !lostFocus && setTimeout( action(() => { this._editing = true; }), 0 ); } } // staticDisplay = () => { return { // eslint-disable-next-line react/jsx-props-no-spreading this._props.fieldContents ? : '' } } renderEditor = () => { return (
{ this._inputref = r; }} style={{ cursor: 'text', outline: 'none', overflow: 'auto', minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20, }} onBlur={e => {this._props.refSelectModeInfo.enabled ? setTimeout(() => {this.setIsFocused(true)}, 1000) : this.finalizeEdit(false, true, false)}} autoFocus onInput={this.onChange} onKeyDown={this.onKeyDown} onPointerDown={e => {e.stopPropagation(); setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0)}} //timeout callback ensures that refSelectMode is properly set onClick={e => e.stopPropagation} onPointerUp={e => e.stopPropagation} onPointerMove={e => {e.stopPropagation(); e.preventDefault()}} dangerouslySetInnerHTML={{ __html: this._displayedContent }} >
); } render() { const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n'); if ((this._editing && gval !== undefined)) { return
{this.renderEditor()}
; } else return ( this._props.contents instanceof ObjectField ? null : (
{this.staticDisplay()}
) ); } }