diff options
| author | bobzel <zzzman@gmail.com> | 2024-10-10 20:06:17 -0400 |
|---|---|---|
| committer | bobzel <zzzman@gmail.com> | 2024-10-10 20:06:17 -0400 |
| commit | 962302d41ba5b086818f5db9ea5103c1e754b66f (patch) | |
| tree | fe7b36ce2ac3c8276e4175e4dd8d5e223e1373a7 /src/client/views/collections/collectionSchema/SchemaCellField.tsx | |
| parent | 3a35e2687e3c7b0c864dd4f00b1002ff088b56d3 (diff) | |
| parent | 040a1c5fd3e80606793e65be3ae821104460511b (diff) | |
Merge branch 'master' into alyssa-starter
Diffstat (limited to 'src/client/views/collections/collectionSchema/SchemaCellField.tsx')
| -rw-r--r-- | src/client/views/collections/collectionSchema/SchemaCellField.tsx | 406 |
1 files changed, 406 insertions, 0 deletions
diff --git a/src/client/views/collections/collectionSchema/SchemaCellField.tsx b/src/client/views/collections/collectionSchema/SchemaCellField.tsx new file mode 100644 index 000000000..5a64ecc62 --- /dev/null +++ b/src/client/views/collections/collectionSchema/SchemaCellField.tsx @@ -0,0 +1,406 @@ +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 { + contents: FieldType; + fieldContents?: FieldViewProps; + editing?: boolean; + oneLine?: boolean; + Document: Doc; + 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<SchemaCellFieldProps> { + 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(<DocumentIconContainer />, { 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 => { + console.log('Update: ' + this._props.Document.title, this._props.fieldKey, fieldVal); + this._unrenderedContent = fieldVal ?? ''; + this.finalizeEdit(false, false, false); + } + ); + } + + componentDidUpdate(prevProps: Readonly<SchemaCellFieldProps>) { + 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; + console.log('Unmount: ' + this._props.Document.title, this._props.fieldKey); + 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 `<span style="text-decoration: ${selfRef ? 'underline' : 'none'}; text-decoration-color: red; color: ${selfRef ? 'gray' : cell?.style.borderTop.replace('2px solid', '')}">${text}</span>`; + }; + + makeSpans = (content: string) => { + let chunkedText = content; + + const pattern = /(this|d(\d+))\.(\w+)/g; + const matches: string[] = []; + let match: RegExpExecArray | null; + + const cells: Map<string, HTMLDivElement> = 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; + 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<HTMLDivElement> | 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(<DocumentIconContainer />, { 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<HTMLInputElement>) => { + 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(); + const cursorPos = this.cursorPosition !== null ? this.cursorPosition + 1 : 0; + 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; + 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 <span className="editableView-static">{this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : ''}</span>; + }; + + renderEditor = () => { + return ( + <div + contentEditable + className="schemaField-editing" + ref={r => { + this._inputref = r; + }} + style={{ cursor: 'text', outline: 'none', overflow: 'auto', 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))} + 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 }}></div> + ); + }; + + render() { + const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n'); + if (this._editing && gval !== undefined) { + return <div className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`}>{this.renderEditor()}</div>; + } else + return this._props.contents instanceof ObjectField ? null : ( + <div + className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`} + style={{ + minHeight: '10px', + whiteSpace: this._props.oneLine ? 'nowrap' : 'pre-line', + width: '100%', + }} + onClick={this.onClick}> + {this.staticDisplay()} + </div> + ); + } +} |
