import { action, computed, IReactionDisposer, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as ReactDOM from 'react-dom/client'; import { DataSym, Doc, DocListCast, Field } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { listSpec } from '../../../../fields/Schema'; import { SchemaHeaderField } from '../../../../fields/SchemaHeaderField'; import { ComputedField } from '../../../../fields/ScriptField'; import { Cast, StrCast } from '../../../../fields/Types'; import { DocServer } from '../../../DocServer'; import './DashFieldView.scss'; import { FormattedTextBox } from './FormattedTextBox'; import React = require('react'); import { emptyFunction, returnFalse, setupMoveUpEvents } from '../../../../Utils'; import { AntimodeMenu, AntimodeMenuProps } from '../../AntimodeMenu'; import { Tooltip } from '@material-ui/core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { CollectionViewType } from '../../../documents/DocumentTypes'; import { NodeSelection } from 'prosemirror-state'; import { OpenWhere } from '../DocumentView'; export class DashFieldView { dom: HTMLDivElement; // container for label and value root: any; constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { this.dom = document.createElement('div'); this.dom.style.width = node.attrs.width; this.dom.style.height = node.attrs.height; this.dom.style.position = 'relative'; this.dom.style.display = 'inline-block'; this.dom.onkeypress = function (e: any) { e.stopPropagation(); }; this.dom.onkeydown = function (e: any) { e.stopPropagation(); }; this.dom.onkeyup = function (e: any) { e.stopPropagation(); }; this.dom.onmousedown = function (e: any) { e.stopPropagation(); }; this.root = ReactDOM.createRoot(this.dom); this.root.render( ); } destroy() { this.root.unmount(); } deselectNode() { this.dom.classList.remove('ProseMirror-selectednode'); } selectNode() { this.dom.classList.add('ProseMirror-selectednode'); } } interface IDashFieldViewInternal { fieldKey: string; docid: string; hideKey: boolean; tbox: FormattedTextBox; width: number; height: number; editable: boolean; node: any; getPos: any; } @observer export class DashFieldViewInternal extends React.Component { _reactionDisposer: IReactionDisposer | undefined; _textBoxDoc: Doc; _fieldKey: string; _fieldStringRef = React.createRef(); @observable _dashDoc: Doc | undefined; constructor(props: IDashFieldViewInternal) { super(props); this._fieldKey = this.props.fieldKey; this._textBoxDoc = this.props.tbox.props.Document; if (this.props.docid) { DocServer.GetRefField(this.props.docid).then(action(async dashDoc => dashDoc instanceof Doc && (this._dashDoc = dashDoc))); } else { this._dashDoc = this.props.tbox.rootDoc; } } componentWillUnmount() { this._reactionDisposer?.(); } public static multiValueDelimeter = ';'; public static fieldContent(textBoxDoc: Doc, dashDoc: Doc, fieldKey: string) { const dashVal = dashDoc[fieldKey] ?? dashDoc[DataSym][fieldKey] ?? (fieldKey === 'PARAMS' ? textBoxDoc[fieldKey] : ''); const fval = dashVal instanceof List ? dashVal.join(DashFieldViewInternal.multiValueDelimeter) : StrCast(dashVal).startsWith(':=') || dashVal === '' ? Doc.Layout(textBoxDoc)[fieldKey] : dashVal; return { boolVal: Cast(fval, 'boolean', null), strVal: Field.toString(fval as Field) || '' }; } // set the display of the field's value (checkbox for booleans, span of text for strings) @computed get fieldValueContent() { if (this._dashDoc) { const { boolVal, strVal } = DashFieldViewInternal.fieldContent(this._textBoxDoc, this._dashDoc, this._fieldKey); // field value is a boolean, so use a checkbox or similar widget to display it if (boolVal === true || boolVal === false) { return ( { if (this._fieldKey.startsWith('_')) Doc.Layout(this._textBoxDoc)[this._fieldKey] = e.target.checked; Doc.SetInPlace(this._dashDoc!, this._fieldKey, e.target.checked, true); }} /> ); } // field value is a string, so display it as an editable span else { // bcz: this is unfortunate, but since this React component is nested within a non-React text box (prosemirror), we can't // use React events. Essentially, React events occur after native events have been processed, so corresponding React events // will never fire because Prosemirror has handled the native events. So we add listeners for native events here. return ( { r?.addEventListener('keydown', e => this.fieldSpanKeyDown(e, r)); r?.addEventListener('blur', e => r && this.updateText(r.textContent!, false)); r?.addEventListener( 'pointerdown', action(e => { // let target = e.target as any; // hrefs are stored on the dataset of the node that wraps the hyerlink // while (target && !target.dataset?.targethrefs) target = target.parentElement; this.props.tbox.EditorView!.dispatch(this.props.tbox.EditorView!.state.tr.setSelection(new NodeSelection(this.props.tbox.EditorView!.state.doc.resolve(this.props.getPos())))); // FormattedTextBoxComment.update(this.props.tbox, this.props.tbox.EditorView!, undefined, target?.dataset?.targethrefs, target?.dataset.linkdoc); // e.stopPropagation(); }) ); }}> {strVal} ); } } } // we need to handle all key events on the input span or else they will propagate to prosemirror. @action fieldSpanKeyDown = (e: KeyboardEvent, span: HTMLSpanElement) => { if (e.key === 'c' && (e.ctrlKey || e.metaKey)) { navigator.clipboard.writeText(window.getSelection()?.toString() || ''); return; } if (e.key === 'Enter') { // handle the enter key by "submitting" the current text to Dash's database. this.updateText(span.textContent!, true); e.preventDefault(); // prevent default to avoid a newline from being generated and wiping out this field view } if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { // handle ctrl-A to select all the text within the span if (window.getSelection) { const range = document.createRange(); range.selectNodeContents(span); window.getSelection()!.removeAllRanges(); window.getSelection()!.addRange(range); } e.preventDefault(); //prevent default so that all the text in the prosemirror text box isn't selected } if (!this.props.editable) { e.preventDefault(); } e.stopPropagation(); // we need to handle all events or else they will propagate to prosemirror. }; @action updateText = (nodeText: string, forceMatch: boolean) => { if (nodeText) { const newText = nodeText.startsWith(':=') || nodeText.startsWith('=:=') ? ':=-computed-' : nodeText; // look for a document whose id === the fieldKey being displayed. If there's a match, then that document // holds the different enumerated values for the field in the titles of its collected documents. // if there's a partial match from the start of the input text, complete the text --- TODO: make this an auto suggest box and select from a drop down. DocServer.GetRefField(this._fieldKey).then(options => { let modText = ''; options instanceof Doc && DocListCast(options.data).forEach(opt => (forceMatch ? StrCast(opt.title).startsWith(newText) : StrCast(opt.title) === newText) && (modText = StrCast(opt.title))); if (modText) { // elementfieldSpan.innerHTML = this._dashDoc![this._fieldKey as string] = modText; Doc.SetInPlace(this._dashDoc!, this._fieldKey, modText, true); } // if the text starts with a ':=' then treat it as an expression by making a computed field from its value storing it in the key else if (nodeText.startsWith(':=')) { this._dashDoc![DataSym][this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(2)); } else if (nodeText.startsWith('=:=')) { Doc.Layout(this._textBoxDoc)[this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(3)); } else { if (Number(newText).toString() === newText) { if (this._fieldKey.startsWith('_')) Doc.Layout(this._textBoxDoc)[this._fieldKey] = Number(newText); Doc.SetInPlace(this._dashDoc!, this._fieldKey, Number(newText), true); } else { const splits = newText.split(DashFieldViewInternal.multiValueDelimeter); if (this._fieldKey !== 'PARAMS' || !this._textBoxDoc[this._fieldKey] || this._dashDoc?.PARAMS) { const strVal = splits.length > 1 ? new List(splits) : newText; if (this._fieldKey.startsWith('_')) Doc.Layout(this._textBoxDoc)[this._fieldKey] = strVal; Doc.SetInPlace(this._dashDoc!, this._fieldKey, strVal, true); } } } }); } }; createPivotForField = (e: React.MouseEvent) => { let container = this.props.tbox.props.ContainingCollectionView; while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { container = container.props.ContainingCollectionView; } if (container) { const alias = Doc.MakeAlias(container.props.Document); alias._viewType = CollectionViewType.Time; let list = Cast(alias._columnHeaders, listSpec(SchemaHeaderField)); if (!list) { alias._columnHeaders = list = new List(); } list.map(c => c.heading).indexOf(this._fieldKey) === -1 && list.push(new SchemaHeaderField(this._fieldKey, '#f1efeb')); list.map(c => c.heading).indexOf('text') === -1 && list.push(new SchemaHeaderField('text', '#f1efeb')); alias._pivotField = this._fieldKey.startsWith('#') ? '#' : this._fieldKey; this.props.tbox.props.addDocTab(alias, OpenWhere.addRight); } }; // clicking on the label creates a pivot view collection of all documents // in the same collection. The pivot field is the fieldKey of this label onPointerDownLabelSpan = (e: any) => { setupMoveUpEvents(this, e, returnFalse, returnFalse, e => { DashFieldViewMenu.createFieldView = this.createPivotForField; DashFieldViewMenu.Instance.show(e.clientX, e.clientY + 16, this._fieldKey); }); }; render() { return (
{this.props.hideKey ? null : ( {this._fieldKey} )} {this.props.fieldKey.startsWith('#') ? null : this.fieldValueContent}
); } } @observer export class DashFieldViewMenu extends AntimodeMenu { static Instance: DashFieldViewMenu; static createFieldView: (e: React.MouseEvent) => void = emptyFunction; constructor(props: any) { super(props); DashFieldViewMenu.Instance = this; } @action showFields = (e: React.MouseEvent) => { DashFieldViewMenu.createFieldView(e); DashFieldViewMenu.Instance.fadeOut(true); }; @observable _fieldKey = ''; @action public show = (x: number, y: number, fieldKey: string) => { this._fieldKey = fieldKey; this.jumpTo(x, y, true); const hideMenu = () => { this.fadeOut(true); document.removeEventListener('pointerdown', hideMenu, true); }; document.addEventListener('pointerdown', hideMenu, true); }; render() { return this.getElement( {`Show Pivot Viewer for '${this._fieldKey}'`}}> ); } }