import React = require('react'); import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import { extname } from 'path'; import DatePicker from 'react-datepicker'; import { CellInfo } from 'react-table'; import { DateField } from '../../../../fields/DateField'; import { Doc, DocListCast, Field, Opt } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; import { SchemaHeaderField } from '../../../../fields/SchemaHeaderField'; import { ComputedField } from '../../../../fields/ScriptField'; import { BoolCast, Cast, DateCast, FieldValue, StrCast } from '../../../../fields/Types'; import { ImageField } from '../../../../fields/URLField'; import { emptyFunction, Utils } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; import { DocumentManager } from '../../../util/DocumentManager'; import { DragManager } from '../../../util/DragManager'; import { KeyCodes } from '../../../util/KeyCodes'; import { CompileScript } from '../../../util/Scripting'; import { SearchUtil } from '../../../util/SearchUtil'; import { SnappingManager } from '../../../util/SnappingManager'; import { undoBatch } from '../../../util/UndoManager'; import '../../../views/DocumentDecorations.scss'; import { EditableView } from '../../EditableView'; import { MAX_ROW_HEIGHT } from '../../global/globalCssVariables.scss'; import { DocumentIconContainer } from '../../nodes/DocumentIcon'; import { OverlayView } from '../../OverlayView'; import { CollectionView } from '../CollectionView'; import './CollectionSchemaView.scss'; import { OpenWhere } from '../../nodes/DocumentView'; import { PinProps } from '../../nodes/trails'; // intialize cell properties export interface CellProps { row: number; col: number; rowProps: CellInfo; // currently unused CollectionView: Opt; // currently unused ContainingCollection: Opt; Document: Doc; // column name fieldKey: string; // currently unused renderDepth: number; // called when a button is pressed on the node itself addDocTab: (document: Doc, where: OpenWhere) => boolean; pinToPres: (document: Doc, pinProps: PinProps) => void; moveDocument?: (document: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; isFocused: boolean; changeFocusedCellByIndex: (row: number, col: number) => void; // set whether the cell is in the isEditing mode setIsEditing: (isEditing: boolean) => void; isEditable: boolean; setPreviewDoc: (doc: Doc) => void; setComputed: (script: string, doc: Doc, field: string, row: number, col: number) => boolean; getField: (row: number, col?: number) => void; // currnetly unused showDoc: (doc: Doc | undefined, dataDoc?: any, screenX?: number, screenY?: number) => void; } @observer export class CollectionSchemaCell extends React.Component { // return a field key that is corrected for whether it COMMENT public static resolvedFieldKey(column: string, rowDoc: Doc) { const fieldKey = column; if (fieldKey.startsWith('*')) { const rootKey = fieldKey.substring(1); const allKeys = [...Array.from(Object.keys(rowDoc)), ...Array.from(Object.keys(Doc.GetProto(rowDoc)))]; const matchedKeys = allKeys.filter(key => key.includes(rootKey)); if (matchedKeys.length) return matchedKeys[0]; } return fieldKey; } @observable protected _isEditing: boolean = false; protected _focusRef = React.createRef(); protected _rowDoc = this.props.rowProps.original; // Gets the serialized data in proto form of the base proto that this document's proto inherits from protected _rowDataDoc = Doc.GetProto(this.props.rowProps.original); // methods for dragging and dropping protected _dropDisposer?: DragManager.DragDropDisposer; @observable contents: string = ''; componentDidMount() { document.addEventListener('keydown', this.onKeyDown); } componentWillUnmount() { document.removeEventListener('keydown', this.onKeyDown); } @action onKeyDown = (e: KeyboardEvent): void => { // If a cell is editable and clicked, hitting enter shoudl allow the user to edit it if (this.props.isFocused && this.props.isEditable && e.keyCode === KeyCodes.ENTER) { document.removeEventListener('keydown', this.onKeyDown); this._isEditing = true; this.props.setIsEditing(true); } }; @action isEditingCallback = (isEditing: boolean): void => { // a general method that takes a boolean that determines whether the cell should be in // is-editing mode // remove the event listener if it's there document.removeEventListener('keydown', this.onKeyDown); // it's not already in is-editing mode, re-add the event listener isEditing && document.addEventListener('keydown', this.onKeyDown); this._isEditing = isEditing; this.props.setIsEditing(isEditing); this.props.changeFocusedCellByIndex(this.props.row, this.props.col); }; @action onPointerDown = async (e: React.PointerEvent): Promise => { // pan to the cell this.onItemDown(e); // focus on it this.props.changeFocusedCellByIndex(this.props.row, this.props.col); this.props.setPreviewDoc(this.props.rowProps.original); let url: string; if ((url = StrCast(this.props.rowProps.row.href))) { // opens up the the doc in a new window, blurring the old one try { new URL(url); const temp = window.open(url)!; temp.blur(); window.focus(); } catch {} } const doc = Cast(this._rowDoc[this.renderFieldKey], Doc, null); doc && this.props.setPreviewDoc(doc); }; @undoBatch applyToDoc = (doc: Doc, row: number, col: number, run: (args?: { [name: string]: any }) => any) => { // apply a specified change to the cell const res = run({ this: doc, $r: row, $c: col, $: (r: number = 0, c: number = 0) => this.props.getField(r + row, c + col) }); if (!res.success) return false; // change what is rendered to this new changed cell content doc[this.renderFieldKey] = res.result; return true; // return whether the change was successful }; private drop = (e: Event, de: DragManager.DropEvent) => { // if the drag has data at its completion if (de.complete.docDragData) { // if only one doc was dragged if (de.complete.docDragData.draggedDocuments.length === 1) { // update the renderFieldKey this._rowDataDoc[this.renderFieldKey] = de.complete.docDragData.draggedDocuments[0]; } else { // create schema document reflecting the new column arrangement const coll = Docs.Create.SchemaDocument([new SchemaHeaderField('title', '#f1efeb')], de.complete.docDragData.draggedDocuments, {}); this._rowDataDoc[this.renderFieldKey] = coll; } e.stopPropagation(); } }; protected dropRef = (ele: HTMLElement | null) => { // if the drop disposer is not undefined, run its function this._dropDisposer?.(); // if ele is not null, give ele a non-undefined drop disposer ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this))); }; returnHighlights(contents: string, positions?: number[]) { if (positions) { const results = []; StrCast(this.props.Document._searchString); const length = StrCast(this.props.Document._searchString).length; const color = contents ? 'black' : 'grey'; results.push( {contents?.slice(0, positions[0])} ); positions.forEach((num, cur) => { results.push( {contents?.slice(num, num + length)} ); let end = 0; cur === positions.length - 1 ? (end = contents.length) : (end = positions[cur + 1]); results.push( {contents?.slice(num + length, end)} ); }); return results; } return {contents ? contents?.valueOf() : 'undefined'}; } @computed get renderFieldKey() { // gets the resolved field key of this cell return CollectionSchemaCell.resolvedFieldKey(this.props.rowProps.column.id!, this.props.rowProps.original); } onItemDown = async (e: React.PointerEvent) => { // if the document is a document used to change UI for search results in schema view if (this.props.Document._searchDoc) { const aliasdoc = await SearchUtil.GetAliasesOfDocument(this._rowDataDoc); const targetContext = aliasdoc.length <= 0 ? undefined : Cast(aliasdoc[0].context, Doc, null); // Jump to the this document DocumentManager.Instance.jumpToDocument(this._rowDoc, { willPan: true }, emptyFunction, targetContext ? [targetContext] : [], () => this.props.setPreviewDoc(this._rowDoc)); } }; renderCellWithType(type: string | undefined) { const dragRef: React.RefObject = React.createRef(); // the column const fieldKey = this.renderFieldKey; // the exact cell const field = this._rowDoc[fieldKey]; const onPointerEnter = (e: React.PointerEvent): void => { // e.buttons === 1 means the left moue pointer is down if (e.buttons === 1 && SnappingManager.GetIsDragging() && (type === 'document' || type === undefined)) { dragRef.current!.className = 'collectionSchemaView-cellContainer doc-drag-over'; } }; const onPointerLeave = (e: React.PointerEvent): void => { // change the class name to indicate that the cell is no longer being dragged dragRef.current!.className = 'collectionSchemaView-cellContainer'; }; let contents = Field.toString(field as Field); // display 2 hyphens instead of a blank box for empty cells contents = contents === '' ? '--' : contents; // classname reflects the tatus of the cell let className = 'collectionSchemaView-cellWrapper'; if (this._isEditing) className += ' editing'; if (this.props.isFocused && this.props.isEditable) className += ' focused'; if (this.props.isFocused && !this.props.isEditable) className += ' inactive'; const positions = []; if (StrCast(this.props.Document._searchString).toLowerCase() !== '') { // term is ...promise pending... if the field is a Promise, otherwise it is the cell's contents let term = field instanceof Promise ? '...promise pending...' : contents.toLowerCase(); const search = StrCast(this.props.Document._searchString).toLowerCase(); let start = term.indexOf(search); let tally = 0; // if search is found in term if (start !== -1) { positions.push(start); } // if search is found in term, continue finding all instances of search in term while (start < contents?.length && start !== -1) { term = term.slice(start + search.length + 1); tally += start + search.length + 1; start = term.indexOf(search); positions.push(tally + start); } // remove the last position if (positions.length > 1) { positions.pop(); } } const placeholder = type === 'number' ? '0' : contents === '' ? '--' : 'undefined'; return (
(this._isEditing = true))} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave}>
{!this.props.Document._searchDoc ? ( { const cfield = ComputedField.WithoutComputed(() => FieldValue(field)); const cscript = cfield instanceof ComputedField ? cfield.script.originalScript : undefined; const cfinalScript = cscript?.split('return')[cscript.split('return').length - 1]; return cscript ? (cfinalScript?.endsWith(';') ? `:=${cfinalScript?.substring(0, cfinalScript.length - 2)}` : cfinalScript) : Field.IsField(cfield) ? Field.toScriptString(cfield) : ''; }} SetValue={action((value: string) => { // sets what is displayed after the user makes an input let retVal = false; if (value.startsWith(':=') || value.startsWith('=:=')) { // decides how to compute a value when given either of the above strings const script = value.substring(value.startsWith('=:=') ? 3 : 2); retVal = this.props.setComputed(script, value.startsWith(':=') ? this._rowDataDoc : this._rowDoc, this.renderFieldKey, this.props.row, this.props.col); } else { // check if the input is a number let inputIsNum = true; for (const s of value) { if (isNaN(parseInt(s)) && !(s === '.') && !(s === ',')) { inputIsNum = false; } } // check if the input is a boolean const inputIsBool: boolean = value === 'false' || value === 'true'; // what to do in the case if (!inputIsNum && !inputIsBool && !value.startsWith('=')) { // if it's not a number, it's a string, and should be processed as such // strips the string of quotes when it is edited to prevent quotes form being added to the text automatically // after each edit let valueSansQuotes = value; if (this._isEditing) { const vsqLength = valueSansQuotes.length; // get rid of outer quotes valueSansQuotes = valueSansQuotes.substring(value.startsWith('"') ? 1 : 0, valueSansQuotes.charAt(vsqLength - 1) === '"' ? vsqLength - 1 : vsqLength); } let inputAsString = '"'; // escape any quotes in the string for (const i of valueSansQuotes) { if (i === '"') { inputAsString += '\\"'; } else { inputAsString += i; } } // add a closing quote inputAsString += '"'; //two options here: we can strip off outer quotes or we can figure out what's going on with the script const script = CompileScript(inputAsString, { requiredType: type, typecheck: false, editable: true, addReturn: true, params: { this: Doc.name, $r: 'number', $c: 'number', $: 'any' } }); const changeMade = inputAsString.length !== value.length || inputAsString.length - 2 !== value.length; // change it if a change is made, otherwise, just compile using the old cell conetnts script.compiled && (retVal = this.applyToDoc(changeMade ? this._rowDoc : this._rowDataDoc, this.props.row, this.props.col, script.run)); // handle numbers and expressions } else if (inputIsNum || value.startsWith('=')) { //TODO: make accept numbers const inputscript = value.substring(value.startsWith('=') ? 1 : 0); // if commas are not stripped, the parser only considers the numbers after the last comma let inputSansCommas = ''; for (const s of inputscript) { if (!(s === ',')) { inputSansCommas += s; } } const script = CompileScript(inputSansCommas, { requiredType: type, typecheck: false, editable: true, addReturn: true, params: { this: Doc.name, $r: 'number', $c: 'number', $: 'any' } }); const changeMade = value.length - 2 !== value.length; script.compiled && (retVal = this.applyToDoc(changeMade ? this._rowDoc : this._rowDataDoc, this.props.row, this.props.col, script.run)); // handle booleans } else if (inputIsBool) { const script = CompileScript(value, { requiredType: type, typecheck: false, editable: true, addReturn: true, params: { this: Doc.name, $r: 'number', $c: 'number', $: 'any' } }); const changeMade = value.length - 2 !== value.length; script.compiled && (retVal = this.applyToDoc(changeMade ? this._rowDoc : this._rowDataDoc, this.props.row, this.props.col, script.run)); } } if (retVal) { this._isEditing = false; // need to set this here. otherwise, the assignment of the field will invalidate & cause render() to be called with the wrong value for 'editing' this.props.setIsEditing(false); } return retVal; })} OnFillDown={async (value: string) => { // computes all of the value preceded by := const script = CompileScript(value, { requiredType: type, typecheck: false, editable: true, addReturn: true, params: { this: Doc.name, $r: 'number', $c: 'number', $: 'any' } }); script.compiled && DocListCast(this.props.Document[this.props.fieldKey]).forEach((doc, i) => value.startsWith(':=') ? this.props.setComputed(value.substring(2), Doc.GetProto(doc), this.renderFieldKey, i, this.props.col) : this.applyToDoc(Doc.GetProto(doc), i, this.props.col, script.run) ); }} /> ) : ( this.returnHighlights(contents, positions) )}
); } render() { return this.renderCellWithType(undefined); } } @observer export class CollectionSchemaNumberCell extends CollectionSchemaCell { render() { return this.renderCellWithType('number'); } } @observer export class CollectionSchemaBooleanCell extends CollectionSchemaCell { render() { return this.renderCellWithType('boolean'); } } @observer export class CollectionSchemaStringCell extends CollectionSchemaCell { render() { return this.renderCellWithType('string'); } } @observer export class CollectionSchemaDateCell extends CollectionSchemaCell { @computed get _date(): Opt { // if the cell is a date field, cast then contents to a date. Otherrwwise, make the contents undefined. return this._rowDoc[this.renderFieldKey] instanceof DateField ? DateCast(this._rowDoc[this.renderFieldKey]) : undefined; } @action handleChange = (date: any) => { // const script = CompileScript(date.toString(), { requiredType: "Date", addReturn: true, params: { this: Doc.name } }); // if (script.compiled) { // this.applyToDoc(this._document, this.props.row, this.props.col, script.run); // } else { // ^ DateCast is always undefined for some reason, but that is what the field should be set to this._rowDoc[this.renderFieldKey] = new DateField(date as Date); //} }; render() { return !this.props.isFocused ? ( {this._date ? Field.toString(this._date as Field) : '--'} ) : ( this.handleChange(date)} onChange={date => this.handleChange(date)} /> ); } } @observer export class CollectionSchemaDocCell extends CollectionSchemaCell { _overlayDisposer?: () => void; @computed get _doc() { return FieldValue(Cast(this._rowDoc[this.renderFieldKey], Doc)); } @action onSetValue = (value: string) => { this._doc && (Doc.GetProto(this._doc).title = value); const script = CompileScript(value, { addReturn: true, typecheck: true, transformer: DocumentIconContainer.getTransformer(), }); // compile the script const results = script.compiled && script.run(); // if the script was compiled and run if (results && results.success) { this._rowDoc[this.renderFieldKey] = results.result; return true; } return false; }; componentWillUnmount() { this.onBlur(); } onBlur = () => { this._overlayDisposer?.(); }; onFocus = () => { this.onBlur(); this._overlayDisposer = OverlayView.Instance.addElement(, { x: 0, y: 0 }); }; @action isEditingCallback = (isEditing: boolean): void => { // the isEditingCallback from a general CollectionSchemaCell document.removeEventListener('keydown', this.onKeyDown); isEditing && document.addEventListener('keydown', this.onKeyDown); this._isEditing = isEditing; this.props.setIsEditing(isEditing); this.props.changeFocusedCellByIndex(this.props.row, this.props.col); }; render() { // if there's a doc, render it return !this._doc ? ( this.renderCellWithType('document') ) : (
StrCast(this._doc?.title)} SetValue={action((value: string) => { this.onSetValue(value); return true; })} />
this._doc && this.props.addDocTab(this._doc, OpenWhere.addRight)} className="collectionSchemaView-cellContents-docButton">
); } } @observer export class CollectionSchemaImageCell extends CollectionSchemaCell { choosePath(url: URL) { if (url.protocol === 'data') return url.href; // if the url ises the data protocol, just return the href if (url.href.indexOf(window.location.origin) === -1) return Utils.CorsProxy(url.href); // otherwise, put it through the cors proxy erver if (!/\.(png|jpg|jpeg|gif|webp)$/.test(url.href.toLowerCase())) return url.href; //Why is this here — good question const ext = extname(url.href); return url.href.replace(ext, '_o' + ext); } render() { const field = Cast(this._rowDoc[this.renderFieldKey], ImageField, null); // retrieve the primary image URL that is being rendered from the data doc const alts = DocListCast(this._rowDoc[this.renderFieldKey + '-alternates']); // retrieve alternate documents that may be rendered as alternate images const altpaths = alts .map(doc => Cast(doc[Doc.LayoutFieldKey(doc)], ImageField, null)?.url) .filter(url => url) .map(url => this.choosePath(url)); // access the primary layout data of the alternate documents const paths = field ? [this.choosePath(field.url), ...altpaths] : altpaths; // If there is a path, follow it; otherwise, follow a link to a default image icon const url = paths.length ? paths : [Utils.CorsProxy('http://www.cs.brown.edu/~bcz/noImage.png')]; const aspect = Doc.NativeAspect(this._rowDoc); // aspect ratio let width = Math.min(75, this.props.rowProps.width); // get a with that is no smaller than 75px const height = Math.min(75, width / aspect); // get a height either proportional to that or 75 px width = height * aspect; // increase the width of the image if necessary to maintain proportionality const reference = React.createRef(); return (
); } } @observer export class CollectionSchemaListCell extends CollectionSchemaCell { _overlayDisposer?: () => void; @computed get _field() { return this._rowDoc[this.renderFieldKey]; } @computed get _optionsList() { return this._field as List; } @observable private _opened = false; // whether the list is opened @observable private _text = 'select an item'; @observable private _selectedNum = 0; // the index of the list item selected @action onSetValue = (value: string) => { // change if it's a document this._optionsList[this._selectedNum] = this._text = value; (this._field as List).splice(this._selectedNum, 1, value); }; @action onSelected = (element: string, index: number) => { // if an item is selected, the private variables should update to reflect this this._text = element; this._selectedNum = index; }; onFocus = () => { this._overlayDisposer?.(); this._overlayDisposer = OverlayView.Instance.addElement(, { x: 0, y: 0 }); }; render() { const link = false; const reference = React.createRef(); // if the list is not opened, don't display it; otherwise, do. if (this._optionsList?.length) { const options = !this._opened ? null : (
{this._optionsList.map((element, index) => { const val = Field.toString(element); return (
this.onSelected(StrCast(element), index)}> {val}
); })}
); const plainText =
{this._text}
; const textarea = (
this._text} SetValue={action((value: string) => { // add special for params this.onSetValue(value); return true; })} />
); //☰ return (
{link ? plainText : textarea}
{options}
); } return this.renderCellWithType('list'); } } @observer export class CollectionSchemaCheckboxCell extends CollectionSchemaCell { @computed get _isChecked() { return BoolCast(this._rowDoc[this.renderFieldKey]); } render() { const reference = React.createRef(); return (
(this._rowDoc[this.renderFieldKey] = e.target.checked)} />
); } } @observer export class CollectionSchemaButtons extends CollectionSchemaCell { // the navigation buttons for schema view when it is used for search. render() { return !this.props.Document._searchDoc || ![DocumentType.PDF, DocumentType.RTF].includes(StrCast(this._rowDoc.type) as DocumentType) ? ( <> ) : (
); } }