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 { FieldType, ObjectField } from '../../../../fields/ObjectField'; import { Doc } from '../../../../fields/Doc'; import { DocumentView } from '../../nodes/DocumentView'; import DOMPurify from 'dompurify'; /** * The SchemaCellField renders text in schema cells while the user is editing, and updates the * contents of the field based on user input. It handles some cell-side logic for equations, such * as how equations are broken up within the text. * * The current implementation parses innerHTML to create spans based on the text in the cell. * A more robust/safer approach would directly add elements in the react structure, but this * has been challenging to implement. */ export interface SchemaCellFieldProps { Doc: Doc; contents: FieldType | undefined; fieldContents?: FieldViewProps; editing?: boolean; oneLine?: boolean; fieldKey: string; // eslint-disable-next-line no-use-before-define 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.Doc);} // 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.setContent((this._unrenderedContent = this._props.GetValue() ?? '')); this.setupRefSelect(this.refSelectConditionMet); } 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._editing && this.finalizeEdit(false, false, false); } ); } 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; }); } _unmounted = false; componentWillUnmount(): void { this._unmounted = true; 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]); } } matches.forEach(m => { chunkedText = chunkedText.replace(m, this.generateSpan(m, cells.get(m))); }); return chunkedText; }; /** * Sets the rendered content of the cell to save user inputs. * @param content the content to set * @param restoreCursorPos whether the cursor should be set back to where it was rather than the 0th index; should usually be true */ @action setContent = (content: string, restoreCursorPos?: boolean) => { const pos = this.cursorPosition; this._displayedContent = DOMPurify.sanitize(this.makeSpans(content)); restoreCursorPos && setTimeout(() => this.setCursorPosition(pos)); }; //Called from schemaview when a cell is selected to add a reference to the equation /** * Inserts text at the given index. * @param text The text to append. * @param atPos he index at which to insert the text. If empty, defaults to end. */ @action insertText = (text: string, atPos?: boolean) => { const content = this._unrenderedContent; const cursorPos = this.cursorPosition; const robustPos = cursorPos ?? content.length; const newText = atPos ? 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; this._editing && setTimeout(() => this._inputref?.focus()); return wasFocused !== this._editing; }; /** * Gets the cursor's position index within the text being edited. */ 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); }; //This function checks if a visual update (eg. coloring a cell reference) should be made. It's meant to //save on processing upkeep vs. constantly rerendering, but I think the savings are minimal for now shouldUpdate = (prevVal: string, currVal: string) => { if (this._props.getCells(currVal).length !== this._props.getCells(prevVal).length) return true; }; 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) => { switch (e.key) { case 'Tab': e.stopPropagation(); this.finalizeEdit(e.shiftKey, false, false); break; case 'Backspace': e.stopPropagation(); break; case 'Enter': e.stopPropagation(); !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)); break; case ' ': { e.stopPropagation(); const cursorPos = this.cursorPosition !== null ? this.cursorPosition + 1 : 0; setTimeout(() => { this.setContent(this._unrenderedContent); setTimeout(() => this.setCursorPosition(cursorPos)); }); } break; case 'Shift': case 'Alt': case 'Meta': case 'Control': case ':': 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._unmounted) { return; } if (this._unrenderedContent.replace(this.selfRefPattern, '') !== this._unrenderedContent) { if (this._dependencyMessageShown) { this._dependencyMessageShown = false; } else alert(`Circular dependency detected. Please update the field at ${this.selfRefPattern}.`); this._dependencyMessageShown = true; return; } this.setContent(this._unrenderedContent); if (!this._props.SetValue(this._unrenderedContent, shiftDown, enterKey) && !lostFocus) { setTimeout(action(() => (this._editing = true))); } this._editing = false; }; staticDisplay = () => { return {this._props.fieldContents ? : ''}; }; renderEditor = () => { return (
(this._inputref = r)} style={{ minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20 }} onBlur={() => (this._props.refSelectModeInfo.enabled ? setTimeout(() => this.setIsFocused(true), 1000) : this.finalizeEdit(false, true, false))} 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 }}>
); }; onFocus = () => { 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)); } }; onBlur = action(() => { this._editing = false; }); 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()}
); } }