diff options
Diffstat (limited to 'src/client/views/collections/old_collectionSchema')
7 files changed, 3421 insertions, 0 deletions
diff --git a/src/client/views/collections/old_collectionSchema/OldCollectionSchemaCells.tsx b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaCells.tsx new file mode 100644 index 000000000..fb93d8b8e --- /dev/null +++ b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaCells.tsx @@ -0,0 +1,681 @@ +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'; + +// intialize cell properties +export interface CellProps { + row: number; + col: number; + rowProps: CellInfo; + // currently unused + CollectionView: Opt<CollectionView>; + // currently unused + ContainingCollection: Opt<CollectionView>; + Document: Doc; + // column name + fieldKey: string; + // currently unused + renderDepth: number; + // called when a button is pressed on the node itself + addDocTab: (document: Doc, where: string) => boolean; + pinToPres: (document: Doc) => 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<CellProps> { + // 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<HTMLDivElement>(); + 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<void> => { + // 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( + <span key="-1" style={{ color }}> + {contents?.slice(0, positions[0])} + </span> + ); + positions.forEach((num, cur) => { + results.push( + <span key={'start' + cur} style={{ backgroundColor: '#FFFF00', color }}> + {contents?.slice(num, num + length)} + </span> + ); + let end = 0; + cur === positions.length - 1 ? (end = contents.length) : (end = positions[cur + 1]); + results.push( + <span key={'end' + cur} style={{ color }}> + {contents?.slice(num + length, end)} + </span> + ); + }); + return results; + } + return <span style={{ color: contents ? 'black' : 'grey' }}>{contents ? contents?.valueOf() : 'undefined'}</span>; + } + + @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, false, emptyFunction, targetContext ? [targetContext] : [], undefined, undefined, undefined, () => this.props.setPreviewDoc(this._rowDoc)); + } + }; + + renderCellWithType(type: string | undefined) { + const dragRef: React.RefObject<HTMLDivElement> = 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 ( + <div + className="collectionSchemaView-cellContainer" + style={{ cursor: field instanceof Doc ? 'grab' : 'auto' }} + ref={dragRef} + onPointerDown={this.onPointerDown} + onClick={action(e => (this._isEditing = true))} + onPointerEnter={onPointerEnter} + onPointerLeave={onPointerLeave}> + <div className={className} ref={this._focusRef} tabIndex={-1}> + <div className="collectionSchemaView-cellContents" ref={type === undefined || type === 'document' ? this.dropRef : null}> + {!this.props.Document._searchDoc ? ( + <EditableView + editing={this._isEditing} + isEditingCallback={this.isEditingCallback} + display={'inline'} + contents={contents} + height={'auto'} + maxHeight={Number(MAX_ROW_HEIGHT)} + placeholder={placeholder} + GetValue={() => { + 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) + )} + </div> + </div> + </div> + ); + } + + 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<DateField> { + // 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 ? ( + <span onPointerDown={this.onPointerDown}>{this._date ? Field.toString(this._date as Field) : '--'}</span> + ) : ( + <DatePicker selected={this._date?.date || new Date()} onSelect={date => 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(<DocumentIconContainer />, { 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') + ) : ( + <div className="collectionSchemaView-cellWrapper" ref={this._focusRef} tabIndex={-1} onPointerDown={this.onPointerDown}> + <div className="collectionSchemaView-cellContents-document" style={{ padding: '5.9px' }} ref={this.dropRef} onFocus={this.onFocus} onBlur={this.onBlur}> + <EditableView + editing={this._isEditing} + isEditingCallback={this.isEditingCallback} + display={'inline'} + contents={this._doc.title || '--'} + height={'auto'} + maxHeight={Number(MAX_ROW_HEIGHT)} + GetValue={() => StrCast(this._doc?.title)} + SetValue={action((value: string) => { + this.onSetValue(value); + return true; + })} + /> + </div> + <div onClick={() => this._doc && this.props.addDocTab(this._doc, 'add:right')} className="collectionSchemaView-cellContents-docButton"> + <FontAwesomeIcon icon="external-link-alt" size="lg" /> + </div> + </div> + ); + } +} + +@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<HTMLDivElement>(); + return ( + <div className="collectionSchemaView-cellWrapper" ref={this._focusRef} tabIndex={-1} onPointerDown={this.onPointerDown}> + <div className="collectionSchemaView-cellContents" key={this._rowDoc[Id]} ref={reference}> + <img src={url[0]} width={paths.length ? width : '20px'} height={paths.length ? height : '20px'} /> + </div> + </div> + ); + } +} + +@observer +export class CollectionSchemaListCell extends CollectionSchemaCell { + _overlayDisposer?: () => void; + + @computed get _field() { + return this._rowDoc[this.renderFieldKey]; + } + @computed get _optionsList() { + return this._field as List<any>; + } + @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<any>).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(<DocumentIconContainer />, { x: 0, y: 0 }); + }; + + render() { + const link = false; + const reference = React.createRef<HTMLDivElement>(); + + // if the list is not opened, don't display it; otherwise, do. + if (this._optionsList?.length) { + const options = !this._opened ? null : ( + <div> + {this._optionsList.map((element, index) => { + const val = Field.toString(element); + return ( + <div className="collectionSchemaView-dropdownOption" key={index} style={{ padding: '6px' }} onPointerDown={e => this.onSelected(StrCast(element), index)}> + {val} + </div> + ); + })} + </div> + ); + + const plainText = <div style={{ padding: '5.9px' }}>{this._text}</div>; + const textarea = ( + <div className="collectionSchemaView-cellContents" key={this._rowDoc[Id]} style={{ padding: '5.9px' }} ref={this.dropRef}> + <EditableView + editing={this._isEditing} + isEditingCallback={this.isEditingCallback} + display={'inline'} + contents={this._text} + height={'auto'} + maxHeight={Number(MAX_ROW_HEIGHT)} + GetValue={() => this._text} + SetValue={action((value: string) => { + // add special for params + this.onSetValue(value); + return true; + })} + /> + </div> + ); + + //☰ + return ( + <div className="collectionSchemaView-cellWrapper" ref={this._focusRef} tabIndex={-1} onPointerDown={this.onPointerDown}> + <div className="collectionSchemaView-cellContents" key={this._rowDoc[Id]} ref={reference}> + <div className="collectionSchemaView-dropDownWrapper"> + <button type="button" className="collectionSchemaView-dropdownButton" style={{ right: 'length', position: 'relative' }} onClick={action(e => (this._opened = !this._opened))}> + <FontAwesomeIcon icon={this._opened ? 'caret-up' : 'caret-down'} size="sm" /> + </button> + <div className="collectionSchemaView-dropdownText"> {link ? plainText : textarea} </div> + </div> + {options} + </div> + </div> + ); + } + return this.renderCellWithType('list'); + } +} + +@observer +export class CollectionSchemaCheckboxCell extends CollectionSchemaCell { + @computed get _isChecked() { + return BoolCast(this._rowDoc[this.renderFieldKey]); + } + + render() { + const reference = React.createRef<HTMLDivElement>(); + return ( + <div className="collectionSchemaView-cellWrapper" ref={this._focusRef} tabIndex={-1} onPointerDown={this.onPointerDown}> + <input type="checkbox" checked={this._isChecked} onChange={e => (this._rowDoc[this.renderFieldKey] = e.target.checked)} /> + </div> + ); + } +} + +@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) ? ( + <></> + ) : ( + <div style={{ paddingTop: 8, paddingLeft: 3 }}> + <button style={{ padding: 2, left: 77 }} onClick={() => Doc.SearchMatchNext(this._rowDoc, true)}> + <FontAwesomeIcon icon="arrow-up" size="sm" /> + </button> + <button style={{ padding: 2 }} onClick={() => Doc.SearchMatchNext(this._rowDoc, false)}> + <FontAwesomeIcon icon="arrow-down" size="sm" /> + </button> + </div> + ); + } +} diff --git a/src/client/views/collections/old_collectionSchema/OldCollectionSchemaHeaders.tsx b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaHeaders.tsx new file mode 100644 index 000000000..32283d76c --- /dev/null +++ b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaHeaders.tsx @@ -0,0 +1,510 @@ +// import React = require("react"); +// import { IconProp } from "@fortawesome/fontawesome-svg-core"; +// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +// import { action, computed, observable, runInAction, trace } from "mobx"; +// import { observer } from "mobx-react"; +// import { Doc, DocListCast, Opt, StrListCast } from "../../../../fields/Doc"; +// import { listSpec } from "../../../../fields/Schema"; +// import { PastelSchemaPalette, SchemaHeaderField } from "../../../../fields/SchemaHeaderField"; +// import { ScriptField } from "../../../../fields/ScriptField"; +// import { Cast, StrCast } from "../../../../fields/Types"; +// import { undoBatch } from "../../../util/UndoManager"; +// import { CollectionView } from "../CollectionView"; +// import { ColumnType } from "./CollectionSchemaView"; +// import "./CollectionSchemaView.scss"; + +// const higflyout = require("@hig/flyout"); +// export const { anchorPoints } = higflyout; +// export const Flyout = higflyout.default; + +// export interface AddColumnHeaderProps { +// createColumn: () => void; +// } + +// @observer +// export class CollectionSchemaAddColumnHeader extends React.Component<AddColumnHeaderProps> { +// // the button that allows the user to add a column +// render() { +// return <button className="add-column" onClick={() => this.props.createColumn()}> +// <FontAwesomeIcon icon="plus" size="sm" /> +// </button>; +// } +// } + +// export interface ColumnMenuProps { +// columnField: SchemaHeaderField; +// // keyValue: string; +// possibleKeys: string[]; +// existingKeys: string[]; +// // keyType: ColumnType; +// typeConst: boolean; +// menuButtonContent: JSX.Element; +// addNew: boolean; +// onSelect: (oldKey: string, newKey: string, addnew: boolean) => void; +// setIsEditing: (isEditing: boolean) => void; +// deleteColumn: (column: string) => void; +// onlyShowOptions: boolean; +// setColumnType: (column: SchemaHeaderField, type: ColumnType) => void; +// setColumnSort: (column: SchemaHeaderField, desc: boolean | undefined) => void; +// anchorPoint?: any; +// setColumnColor: (column: SchemaHeaderField, color: string) => void; +// } +// @observer +// export class CollectionSchemaColumnMenu extends React.Component<ColumnMenuProps> { +// @observable private _isOpen: boolean = false; +// @observable private _node: HTMLDivElement | null = null; + +// componentDidMount() { document.addEventListener("pointerdown", this.detectClick); } + +// componentWillUnmount() { document.removeEventListener("pointerdown", this.detectClick); } + +// @action +// detectClick = (e: PointerEvent) => { +// !this._node?.contains(e.target as Node) && this.props.setIsEditing(this._isOpen = false); +// } + +// @action +// toggleIsOpen = (): void => { +// this.props.setIsEditing(this._isOpen = !this._isOpen); +// } + +// changeColumnType = (type: ColumnType) => { +// this.props.setColumnType(this.props.columnField, type); +// } + +// changeColumnSort = (desc: boolean | undefined) => { +// this.props.setColumnSort(this.props.columnField, desc); +// } + +// changeColumnColor = (color: string) => { +// this.props.setColumnColor(this.props.columnField, color); +// } + +// @action +// setNode = (node: HTMLDivElement): void => { +// if (node) { +// this._node = node; +// } +// } + +// renderTypes = () => { +// if (this.props.typeConst) return (null); + +// const type = this.props.columnField.type; +// return ( +// <div className="collectionSchema-headerMenu-group"> +// <label>Column type:</label> +// <div className="columnMenu-types"> +// <div className={"columnMenu-option" + (type === ColumnType.Any ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.Any)}> +// <FontAwesomeIcon icon={"align-justify"} size="sm" /> +// Any +// </div> +// <div className={"columnMenu-option" + (type === ColumnType.Number ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.Number)}> +// <FontAwesomeIcon icon={"hashtag"} size="sm" /> +// Number +// </div> +// <div className={"columnMenu-option" + (type === ColumnType.String ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.String)}> +// <FontAwesomeIcon icon={"font"} size="sm" /> +// Text +// </div> +// <div className={"columnMenu-option" + (type === ColumnType.Boolean ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.Boolean)}> +// <FontAwesomeIcon icon={"check-square"} size="sm" /> +// Checkbox +// </div> +// <div className={"columnMenu-option" + (type === ColumnType.List ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.List)}> +// <FontAwesomeIcon icon={"list-ul"} size="sm" /> +// List +// </div> +// <div className={"columnMenu-option" + (type === ColumnType.Doc ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.Doc)}> +// <FontAwesomeIcon icon={"file"} size="sm" /> +// Document +// </div> +// <div className={"columnMenu-option" + (type === ColumnType.Image ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.Image)}> +// <FontAwesomeIcon icon={"image"} size="sm" /> +// Image +// </div> +// <div className={"columnMenu-option" + (type === ColumnType.Date ? " active" : "")} onClick={() => this.changeColumnType(ColumnType.Date)}> +// <FontAwesomeIcon icon={"calendar"} size="sm" /> +// Date +// </div> +// </div> +// </div > +// ); +// } + +// renderSorting = () => { +// const sort = this.props.columnField.desc; +// return ( +// <div className="collectionSchema-headerMenu-group"> +// <label>Sort by:</label> +// <div className="columnMenu-sort"> +// <div className={"columnMenu-option" + (sort === true ? " active" : "")} onClick={() => this.changeColumnSort(true)}> +// <FontAwesomeIcon icon="sort-amount-down" size="sm" /> +// Sort descending +// </div> +// <div className={"columnMenu-option" + (sort === false ? " active" : "")} onClick={() => this.changeColumnSort(false)}> +// <FontAwesomeIcon icon="sort-amount-up" size="sm" /> +// Sort ascending +// </div> +// <div className="columnMenu-option" onClick={() => this.changeColumnSort(undefined)}> +// <FontAwesomeIcon icon="times" size="sm" /> +// Clear sorting +// </div> +// </div> +// </div> +// ); +// } + +// renderColors = () => { +// const selected = this.props.columnField.color; + +// const pink = PastelSchemaPalette.get("pink2"); +// const purple = PastelSchemaPalette.get("purple2"); +// const blue = PastelSchemaPalette.get("bluegreen1"); +// const yellow = PastelSchemaPalette.get("yellow4"); +// const red = PastelSchemaPalette.get("red2"); +// const gray = "#f1efeb"; + +// return ( +// <div className="collectionSchema-headerMenu-group"> +// <label>Color:</label> +// <div className="columnMenu-colors"> +// <div className={"columnMenu-colorPicker" + (selected === pink ? " active" : "")} style={{ backgroundColor: pink }} onClick={() => this.changeColumnColor(pink!)}></div> +// <div className={"columnMenu-colorPicker" + (selected === purple ? " active" : "")} style={{ backgroundColor: purple }} onClick={() => this.changeColumnColor(purple!)}></div> +// <div className={"columnMenu-colorPicker" + (selected === blue ? " active" : "")} style={{ backgroundColor: blue }} onClick={() => this.changeColumnColor(blue!)}></div> +// <div className={"columnMenu-colorPicker" + (selected === yellow ? " active" : "")} style={{ backgroundColor: yellow }} onClick={() => this.changeColumnColor(yellow!)}></div> +// <div className={"columnMenu-colorPicker" + (selected === red ? " active" : "")} style={{ backgroundColor: red }} onClick={() => this.changeColumnColor(red!)}></div> +// <div className={"columnMenu-colorPicker" + (selected === gray ? " active" : "")} style={{ backgroundColor: gray }} onClick={() => this.changeColumnColor(gray)}></div> +// </div> +// </div> +// ); +// } + +// renderContent = () => { +// return ( +// <div className="collectionSchema-header-menuOptions"> +// {this.props.onlyShowOptions ? <></> : +// <> +// {this.renderTypes()} +// {this.renderSorting()} +// {this.renderColors()} +// <div className="collectionSchema-headerMenu-group"> +// <button onClick={() => this.props.deleteColumn(this.props.columnField.heading)}>Hide Column</button> +// </div> +// </> +// } +// </div> +// ); +// } + +// render() { +// return ( +// <div className="collectionSchema-header-menu" ref={this.setNode}> +// <Flyout anchorPoint={this.props.anchorPoint ? this.props.anchorPoint : anchorPoints.TOP_CENTER} content={this.renderContent()}> +// <div className="collectionSchema-header-toggler" onClick={() => this.toggleIsOpen()}>{this.props.menuButtonContent}</div> +// </ Flyout > +// </div> +// ); +// } +// } + +// export interface KeysDropdownProps { +// keyValue: string; +// possibleKeys: string[]; +// existingKeys: string[]; +// canAddNew: boolean; +// addNew: boolean; +// onSelect: (oldKey: string, newKey: string, addnew: boolean, filter?: string) => void; +// setIsEditing: (isEditing: boolean) => void; +// width?: string; +// docs?: Doc[]; +// Document: Doc; +// dataDoc: Doc | undefined; +// fieldKey: string; +// ContainingCollectionDoc: Doc | undefined; +// ContainingCollectionView: Opt<CollectionView>; +// active?: (outsideReaction?: boolean) => boolean | undefined; +// openHeader: (column: any, screenx: number, screeny: number) => void; +// col: SchemaHeaderField; +// icon: IconProp; +// } +// @observer +// export class KeysDropdown extends React.Component<KeysDropdownProps> { +// @observable private _key: string = this.props.keyValue; +// @observable private _searchTerm: string = this.props.keyValue + ":"; +// @observable private _isOpen: boolean = false; +// @observable private _node: HTMLDivElement | null = null; +// @observable private _inputRef: React.RefObject<HTMLInputElement> = React.createRef(); + +// @action setSearchTerm = (value: string): void => { this._searchTerm = value; }; +// @action setKey = (key: string): void => { this._key = key; }; +// @action setIsOpen = (isOpen: boolean): void => { this._isOpen = isOpen; }; + +// @action +// onSelect = (key: string): void => { +// this.props.onSelect(this._key, key, this.props.addNew); +// this.setKey(key); +// this._isOpen = false; +// this.props.setIsEditing(false); +// } + +// @action +// setNode = (node: HTMLDivElement): void => { +// if (node) { +// this._node = node; +// } +// } + +// componentDidMount() { +// document.addEventListener("pointerdown", this.detectClick); +// const filters = Cast(this.props.Document._docFilters, listSpec("string")); +// if (filters?.some(filter => filter.split(":")[0] === this._key)) { +// runInAction(() => this.closeResultsVisibility = "contents"); +// } +// } + +// @action +// detectClick = (e: PointerEvent): void => { +// if (this._node && this._node.contains(e.target as Node)) { +// } else { +// this._isOpen = false; +// this.props.setIsEditing(false); +// } +// } + +// private tempfilter: string = ""; +// @undoBatch +// onKeyDown = (e: React.KeyboardEvent): void => { +// if (e.key === "Enter") { +// e.stopPropagation(); +// if (this._searchTerm.includes(":")) { +// const colpos = this._searchTerm.indexOf(":"); +// const temp = this._searchTerm.slice(colpos + 1, this._searchTerm.length); +// if (temp === "") { +// Doc.setDocFilter(this.props.Document, this._key, this.tempfilter, "remove"); +// this.updateFilter(); +// } +// else { +// Doc.setDocFilter(this.props.Document, this._key, this.tempfilter, "remove"); +// this.tempfilter = temp; +// Doc.setDocFilter(this.props.Document, this._key, temp, "check"); +// this.props.col.setColor("green"); +// this.closeResultsVisibility = "contents"; +// } +// } +// else { +// Doc.setDocFilter(this.props.Document, this._key, this.tempfilter, "remove"); +// this.updateFilter(); +// if (this.showKeys.length) { +// this.onSelect(this.showKeys[0]); +// } else if (this._searchTerm !== "" && this.props.canAddNew) { +// this.setSearchTerm(this._searchTerm || this._key); +// this.onSelect(this._searchTerm); +// } +// } +// } +// } + +// onChange = (val: string): void => { +// this.setSearchTerm(val); +// } + +// @action +// onFocus = (e: React.FocusEvent): void => { +// this._isOpen = true; +// this.props.setIsEditing(true); +// } + +// @computed get showKeys() { +// const whitelistKeys = ["context", "author", "*lastModified", "text", "data", "tags", "creationDate"]; +// const keyOptions = this._searchTerm === "" ? this.props.possibleKeys : this.props.possibleKeys.filter(key => key.toUpperCase().indexOf(this._searchTerm.toUpperCase()) > -1); +// const showKeys = new Set<string>(); +// [...keyOptions, ...whitelistKeys].forEach(key => (!Doc.noviceMode || +// whitelistKeys.includes(key) +// || ((!key.startsWith("_") && key[0] === key[0].toUpperCase()) || key[0] === "#")) ? showKeys.add(key) : null); +// return Array.from(showKeys.keys()).filter(key => !this._searchTerm || key.includes(this._searchTerm)); +// } + +// @computed get renderOptions() { +// if (!this._isOpen) { +// this.defaultMenuHeight = 0; +// return (null); +// } +// const options = this.showKeys.map(key => { +// return <div key={key} className="key-option" style={{ +// border: "1px solid lightgray", +// width: this.props.width, maxWidth: this.props.width, overflowX: "hidden", background: "white", +// }} +// onPointerDown={e => { +// e.stopPropagation(); +// }} +// onClick={() => { +// this.onSelect(key); +// this.setSearchTerm(""); +// }}>{key}</div>; +// }); + +// // if search term does not already exist as a group type, give option to create new group type + +// if (this._key !== this._searchTerm.slice(0, this._key.length)) { +// if (this._searchTerm !== "" && this.props.canAddNew) { +// options.push(<div key={""} className="key-option" style={{ +// border: "1px solid lightgray", width: this.props.width, maxWidth: this.props.width, overflowX: "hidden", background: "white", +// }} +// onClick={() => { this.onSelect(this._searchTerm); this.setSearchTerm(""); }}> +// Create "{this._searchTerm}" key</div>); +// } +// } + +// if (options.length === 0) { +// this.defaultMenuHeight = 0; +// } +// else { +// if (this.props.docs) { +// const panesize = this.props.docs.length * 30; +// options.length * 20 + 8 - 10 > panesize ? this.defaultMenuHeight = panesize : this.defaultMenuHeight = options.length * 20 + 8; +// } +// else { +// options.length > 5 ? this.defaultMenuHeight = 108 : this.defaultMenuHeight = options.length * 20 + 8; +// } +// } +// return options; +// } + +// @computed get docSafe() { return DocListCast(this.props.dataDoc?.[this.props.fieldKey]); } + +// @computed get renderFilterOptions() { +// if (!this._isOpen || !this.props.dataDoc) { +// this.defaultMenuHeight = 0; +// return (null); +// } +// const keyOptions: string[] = []; +// const colpos = this._searchTerm.indexOf(":"); +// const temp = this._searchTerm.slice(colpos + 1, this._searchTerm.length); +// this.docSafe.forEach(doc => { +// const key = StrCast(doc[this._key]); +// if (keyOptions.includes(key) === false && key.includes(temp) && key !== "") { +// keyOptions.push(key); +// } +// }); + +// const filters = StrListCast(this.props.Document._docFilters); +// if (filters.some(filter => filter.split(":")[0] === this._key) === false) { +// this.props.col.setColor("rgb(241, 239, 235)"); +// this.closeResultsVisibility = "none"; +// } +// for (let i = 0; i < (filters?.length ?? 0) - 1; i++) { +// if (filters[i] === this.props.col.heading && keyOptions.includes(filters[i].split(":")[1]) === false) { +// keyOptions.push(filters[i + 1]); +// } +// } +// const options = keyOptions.map(key => { +// let bool = false; +// if (filters !== undefined) { +// const ind = filters.findIndex(filter => filter.split(":")[1] === key); +// const fields = ind === -1 ? undefined : filters[ind].split(":"); +// bool = fields ? fields[2] === "check" : false; +// } +// return <div key={key} className="key-option" style={{ +// paddingLeft: 5, textAlign: "left", +// width: this.props.width, maxWidth: this.props.width, overflowX: "hidden", background: "white", backgroundColor: "white", +// }} +// > +// <input type="checkbox" +// onPointerDown={e => e.stopPropagation()} +// onClick={e => e.stopPropagation()} +// onChange={action(e => { +// if (e.target.checked) { +// Doc.setDocFilter(this.props.Document, this._key, key, "check"); +// this.closeResultsVisibility = "contents"; +// this.props.col.setColor("green"); +// } else { +// Doc.setDocFilter(this.props.Document, this._key, key, "remove"); +// this.updateFilter(); +// } +// })} +// checked={bool} +// /> +// <span style={{ paddingLeft: 4 }}> +// {key} +// </span> + +// </div>; +// }); +// if (options.length === 0) { +// this.defaultMenuHeight = 0; +// } +// else { +// if (this.props.docs) { +// const panesize = this.props.docs.length * 30; +// options.length * 20 + 8 - 10 > panesize ? this.defaultMenuHeight = panesize : this.defaultMenuHeight = options.length * 20 + 8; +// } +// else { +// options.length > 5 ? this.defaultMenuHeight = 108 : this.defaultMenuHeight = options.length * 20 + 8; +// } + +// } +// return options; +// } + +// @observable defaultMenuHeight = 0; + +// updateFilter() { +// const filters = Cast(this.props.Document._docFilters, listSpec("string")); +// if (filters === undefined || filters.length === 0 || filters.some(filter => filter.split(":")[0] === this._key) === false) { +// this.props.col.setColor("rgb(241, 239, 235)"); +// this.closeResultsVisibility = "none"; +// } +// } + +// @computed get scriptField() { +// const scriptText = "setDocFilter(containingTreeView, heading, this.title, checked)"; +// const script = ScriptField.MakeScript(scriptText, { this: Doc.name, heading: "string", checked: "string", containingTreeView: Doc.name }); +// return script ? () => script : undefined; +// } +// filterBackground = () => "rgba(105, 105, 105, 0.432)"; +// @observable filterOpen: boolean | undefined = undefined; +// closeResultsVisibility: string = "none"; + +// removeFilters = (e: React.PointerEvent): void => { +// const keyOptions: string[] = []; +// this.docSafe.forEach(doc => { +// const key = StrCast(doc[this._key]); +// if (keyOptions.includes(key) === false) { +// keyOptions.push(key); +// } +// }); + +// Doc.setDocFilter(this.props.Document, this._key, "", "remove"); +// this.props.col.setColor("rgb(241, 239, 235)"); +// this.closeResultsVisibility = "none"; +// } +// render() { +// return ( +// <div style={{ display: "flex", width: '100%', alignContent: 'center', alignItems: 'center' }} ref={this.setNode}> +// <div className="schema-icon" onClick={e => { this.props.openHeader(this.props.col, e.clientX, e.clientY); e.stopPropagation(); }}> +// <FontAwesomeIcon icon={this.props.icon} size="lg" style={{ display: "inline" }} /> +// </div> + +// <div className="keys-dropdown" style={{ zIndex: 1, width: this.props.width, maxWidth: this.props.width }}> +// <input className="keys-search" style={{ width: "100%" }} +// ref={this._inputRef} type="text" +// value={this._searchTerm} placeholder="Column key" +// onKeyDown={this.onKeyDown} +// onChange={e => this.onChange(e.target.value)} +// onClick={(e) => { e.stopPropagation(); this._inputRef.current?.focus(); }} +// onFocus={this.onFocus} ></input> +// <div style={{ display: this.closeResultsVisibility }}> +// <FontAwesomeIcon onPointerDown={this.removeFilters} icon={"times-circle"} size="lg" +// style={{ cursor: "hand", color: "grey", padding: 2, left: -20, top: -1, height: 15, position: "relative" }} /> +// </div> +// {!this._isOpen ? (null) : <div className="keys-options-wrapper" style={{ +// width: this.props.width, maxWidth: this.props.width, height: "auto", +// }}> +// {this._searchTerm.includes(":") ? this.renderFilterOptions : this.renderOptions} +// </div>} +// </div > +// </div> +// ); +// } +// } diff --git a/src/client/views/collections/old_collectionSchema/OldCollectionSchemaMovableColumn.tsx b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaMovableColumn.tsx new file mode 100644 index 000000000..28d2e6ab1 --- /dev/null +++ b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaMovableColumn.tsx @@ -0,0 +1,138 @@ +import React = require('react'); +import { action } from 'mobx'; +import { SchemaHeaderField } from '../../../../fields/SchemaHeaderField'; +import { DragManager } from '../../../util/DragManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { Transform } from '../../../util/Transform'; +import './CollectionSchemaView.scss'; + +export interface MovableColumnProps { + columnRenderer: React.ReactNode; + columnValue: SchemaHeaderField; + allColumns: SchemaHeaderField[]; + reorderColumns: (toMove: SchemaHeaderField, relativeTo: SchemaHeaderField, before: boolean, columns: SchemaHeaderField[]) => void; + ScreenToLocalTransform: () => Transform; +} +export class MovableColumn extends React.Component<MovableColumnProps> { + // The header of the column + private _header?: React.RefObject<HTMLDivElement> = React.createRef(); + // The container of the function that is responsible for moving the column over to a new plac + private _colDropDisposer?: DragManager.DragDropDisposer; + // initial column position + private _startDragPosition: { x: number; y: number } = { x: 0, y: 0 }; + // sensitivity to being dragged, in pixels + private _sensitivity: number = 16; + // Column reference ID + private _dragRef: React.RefObject<HTMLDivElement> = React.createRef(); + + onPointerEnter = (e: React.PointerEvent): void => { + // if the column is left-clicked and it is being dragged + if (e.buttons === 1 && SnappingManager.GetIsDragging()) { + this._header!.current!.className = 'collectionSchema-col-wrapper'; + document.addEventListener('pointermove', this.onDragMove, true); + } + }; + + onPointerLeave = (e: React.PointerEvent): void => { + this._header!.current!.className = 'collectionSchema-col-wrapper'; + document.removeEventListener('pointermove', this.onDragMove, true); + !e.buttons && document.removeEventListener('pointermove', this.onPointerMove); + }; + + onDragMove = (e: PointerEvent): void => { + // only take into account the horizonal direction when a column is dragged + const x = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY); + const rect = this._header!.current!.getBoundingClientRect(); + // Now store the point at the top center of the column when it was in its original position + const bounds = this.props.ScreenToLocalTransform().transformPoint(rect.left + (rect.right - rect.left) / 2, rect.top); + // to be compared with its new horizontal position + const before = x[0] < bounds[0]; + this._header!.current!.className = 'collectionSchema-col-wrapper'; + if (before) this._header!.current!.className += ' col-before'; + if (!before) this._header!.current!.className += ' col-after'; + e.stopPropagation(); + }; + + createColDropTarget = (ele: HTMLDivElement) => { + this._colDropDisposer?.(); + if (ele) { + this._colDropDisposer = DragManager.MakeDropTarget(ele, this.colDrop.bind(this)); + } + }; + + colDrop = (e: Event, de: DragManager.DropEvent) => { + document.removeEventListener('pointermove', this.onDragMove, true); + // we only care about whether the column is shifted to the side + const x = this.props.ScreenToLocalTransform().transformPoint(de.x, de.y); + // get the dimensions of the smallest rectangle that bounds the header + const rect = this._header!.current!.getBoundingClientRect(); + const bounds = this.props.ScreenToLocalTransform().transformPoint(rect.left + (rect.right - rect.left) / 2, rect.top); + // get whether the column was dragged before or after where it is now + const before = x[0] < bounds[0]; + const colDragData = de.complete.columnDragData; + // if there is colDragData, which happen when the drag is complete, reorder the columns according to the established variables + if (colDragData) { + e.stopPropagation(); + this.props.reorderColumns(colDragData.colKey, this.props.columnValue, before, this.props.allColumns); + return true; + } + return false; + }; + + onPointerMove = (e: PointerEvent) => { + const onRowMove = (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + + document.removeEventListener('pointermove', onRowMove); + document.removeEventListener('pointerup', onRowUp); + const dragData = new DragManager.ColumnDragData(this.props.columnValue); + DragManager.StartColumnDrag(this._dragRef.current!, dragData, e.x, e.y); + }; + const onRowUp = (): void => { + document.removeEventListener('pointermove', onRowMove); + document.removeEventListener('pointerup', onRowUp); + }; + // if the left mouse button is the one being held + if (e.buttons === 1) { + const [dx, dy] = this.props.ScreenToLocalTransform().transformDirection(e.clientX - this._startDragPosition.x, e.clientY - this._startDragPosition.y); + // If the movemnt of the drag exceeds the sensitivity value + if (Math.abs(dx) + Math.abs(dy) > this._sensitivity) { + document.removeEventListener('pointermove', this.onPointerMove); + e.stopPropagation(); + + document.addEventListener('pointermove', onRowMove); + document.addEventListener('pointerup', onRowUp); + } + } + }; + + onPointerUp = (e: React.PointerEvent) => { + document.removeEventListener('pointermove', this.onPointerMove); + }; + + @action + onPointerDown = (e: React.PointerEvent, ref: React.RefObject<HTMLDivElement>) => { + this._dragRef = ref; + const [dx, dy] = this.props.ScreenToLocalTransform().transformDirection(e.clientX, e.clientY); + // If the cell thing dragged is not being edited + if (!(e.target as any)?.tagName.includes('INPUT')) { + this._startDragPosition = { x: dx, y: dy }; + document.addEventListener('pointermove', this.onPointerMove); + } + }; + + render() { + const reference = React.createRef<HTMLDivElement>(); + + return ( + <div className="collectionSchema-col" ref={this.createColDropTarget}> + <div className="collectionSchema-col-wrapper" ref={this._header} onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> + <div className="col-dragger" ref={reference} onPointerDown={e => this.onPointerDown(e, reference)} onPointerUp={this.onPointerUp}> + {this.props.columnRenderer} + </div> + </div> + </div> + ); + } +} diff --git a/src/client/views/collections/old_collectionSchema/OldCollectionSchemaMovableRow.tsx b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaMovableRow.tsx new file mode 100644 index 000000000..f872637e5 --- /dev/null +++ b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaMovableRow.tsx @@ -0,0 +1,151 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action } from 'mobx'; +import * as React from 'react'; +import { ReactTableDefaults, RowInfo } from 'react-table'; +import { Doc } from '../../../../fields/Doc'; +import { Cast, FieldValue, StrCast } from '../../../../fields/Types'; +import { DocumentManager } from '../../../util/DocumentManager'; +import { DragManager, dropActionType, SetupDrag } from '../../../util/DragManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { Transform } from '../../../util/Transform'; +import { undoBatch } from '../../../util/UndoManager'; +import { ContextMenu } from '../../ContextMenu'; +import './CollectionSchemaView.scss'; + +export interface MovableRowProps { + rowInfo: RowInfo; + ScreenToLocalTransform: () => Transform; + addDoc: (doc: Doc | Doc[], relativeTo?: Doc, before?: boolean) => boolean; + removeDoc: (doc: Doc | Doc[]) => boolean; + rowFocused: boolean; + textWrapRow: (doc: Doc) => void; + rowWrapped: boolean; + dropAction: string; + addDocTab: any; +} + +export class MovableRow extends React.Component<React.PropsWithChildren<MovableRowProps>> { + private _header?: React.RefObject<HTMLDivElement> = React.createRef(); + private _rowDropDisposer?: DragManager.DragDropDisposer; + + // Event listeners are only necessary when the user is hovering over the table + // Create one when the mouse starts hovering... + onPointerEnter = (e: React.PointerEvent): void => { + if (e.buttons === 1 && SnappingManager.GetIsDragging()) { + this._header!.current!.className = 'collectionSchema-row-wrapper'; + document.addEventListener('pointermove', this.onDragMove, true); + } + }; + // ... and delete it when the mouse leaves + onPointerLeave = (e: React.PointerEvent): void => { + this._header!.current!.className = 'collectionSchema-row-wrapper'; + document.removeEventListener('pointermove', this.onDragMove, true); + }; + // The method for the event listener, reorders columns when dragged to their new locations. + onDragMove = (e: PointerEvent): void => { + const x = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY); + const rect = this._header!.current!.getBoundingClientRect(); + const bounds = this.props.ScreenToLocalTransform().transformPoint(rect.left, rect.top + rect.height / 2); + const before = x[1] < bounds[1]; + this._header!.current!.className = 'collectionSchema-row-wrapper'; + if (before) this._header!.current!.className += ' row-above'; + if (!before) this._header!.current!.className += ' row-below'; + e.stopPropagation(); + }; + componentWillUnmount() { + this._rowDropDisposer?.(); + } + // + createRowDropTarget = (ele: HTMLDivElement) => { + this._rowDropDisposer?.(); + if (ele) { + this._rowDropDisposer = DragManager.MakeDropTarget(ele, this.rowDrop.bind(this)); + } + }; + // Controls what hppens when a row is dragged and dropped + rowDrop = (e: Event, de: DragManager.DropEvent) => { + this.onPointerLeave(e as any); + const rowDoc = FieldValue(Cast(this.props.rowInfo.original, Doc)); + if (!rowDoc) return false; + + const x = this.props.ScreenToLocalTransform().transformPoint(de.x, de.y); + const rect = this._header!.current!.getBoundingClientRect(); + const bounds = this.props.ScreenToLocalTransform().transformPoint(rect.left, rect.top + rect.height / 2); + const before = x[1] < bounds[1]; + + const docDragData = de.complete.docDragData; + if (docDragData) { + e.stopPropagation(); + if (docDragData.draggedDocuments[0] === rowDoc) return true; + const addDocument = (doc: Doc | Doc[]) => this.props.addDoc(doc, rowDoc, before); + const movedDocs = docDragData.draggedDocuments; + return docDragData.dropAction || docDragData.userDropAction + ? docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDoc(d, rowDoc, before) || added, false) + : docDragData.moveDocument + ? movedDocs.reduce((added: boolean, d) => docDragData.moveDocument?.(d, rowDoc, addDocument) || added, false) + : docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDoc(d, rowDoc, before), false); + } + return false; + }; + + onRowContextMenu = (e: React.MouseEvent): void => { + const description = this.props.rowWrapped ? 'Unwrap text on row' : 'Text wrap row'; + ContextMenu.Instance.addItem({ description: description, event: () => this.props.textWrapRow(this.props.rowInfo.original), icon: 'file-pdf' }); + }; + + @undoBatch + @action + move: DragManager.MoveFunction = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDoc) => { + const targetView = targetCollection && DocumentManager.Instance.getDocumentView(targetCollection); + return doc !== targetCollection && doc !== targetView?.props.ContainingCollectionDoc && this.props.removeDoc(doc) && addDoc(doc); + }; + + @action + onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + console.log('yes'); + if (e.key === 'Backspace' || e.key === 'Delete') { + undoBatch(() => this.props.removeDoc(this.props.rowInfo.original)); + } + }; + + render() { + const { children = null, rowInfo } = this.props; + + if (!rowInfo) { + return <ReactTableDefaults.TrComponent>{children}</ReactTableDefaults.TrComponent>; + } + + const { original } = rowInfo; + const doc = FieldValue(Cast(original, Doc)); + + if (!doc) return null; + + const reference = React.createRef<HTMLDivElement>(); + const onItemDown = SetupDrag(reference, () => doc, this.move, StrCast(this.props.dropAction) as dropActionType); + + let className = 'collectionSchema-row'; + if (this.props.rowFocused) className += ' row-focused'; + if (this.props.rowWrapped) className += ' row-wrapped'; + + return ( + <div className={className} onKeyPress={this.onKeyDown} ref={this.createRowDropTarget} onContextMenu={this.onRowContextMenu}> + <div className="collectionSchema-row-wrapper" onKeyPress={this.onKeyDown} ref={this._header} onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> + <ReactTableDefaults.TrComponent onKeyPress={this.onKeyDown}> + <div className="row-dragger"> + <div className="row-option" onClick={undoBatch(() => this.props.removeDoc(this.props.rowInfo.original))}> + <FontAwesomeIcon icon="trash" size="sm" /> + </div> + <div className="row-option" style={{ cursor: 'grab' }} ref={reference} onPointerDown={onItemDown}> + <FontAwesomeIcon icon="grip-vertical" size="sm" /> + </div> + <div className="row-option" onClick={() => this.props.addDocTab(this.props.rowInfo.original, 'add:right')}> + <FontAwesomeIcon icon="external-link-alt" size="sm" /> + </div> + </div> + {children} + </ReactTableDefaults.TrComponent> + </div> + </div> + ); + } +} diff --git a/src/client/views/collections/old_collectionSchema/OldCollectionSchemaView.scss b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaView.scss new file mode 100644 index 000000000..22ce8c8f2 --- /dev/null +++ b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaView.scss @@ -0,0 +1,599 @@ +@import '../../global/globalCssVariables.scss'; +// @import '../../../../../node_modules/react-table/react-table.css'; +.collectionSchemaView-container { + border-width: $COLLECTION_BORDER_WIDTH; + border-color: $medium-gray; + border-style: solid; + border-radius: $border-radius; + box-sizing: border-box; + position: relative; + top: 0; + width: 100%; + height: 100%; + margin-top: 0; + transition: top 0.5s; + display: flex; + justify-content: space-between; + flex-wrap: nowrap; + touch-action: none; + div { + touch-action: none; + } + .collectionSchemaView-tableContainer { + width: 100%; + height: 100%; + } + .collectionSchemaView-dividerDragger { + position: relative; + height: 100%; + width: $SCHEMA_DIVIDER_WIDTH; + z-index: 20; + right: 0; + top: 0; + background: gray; + cursor: col-resize; + } + // .documentView-node:first-child { + // background: $white; + // } +} + +.collectionSchemaView-searchContainer { + border-width: $COLLECTION_BORDER_WIDTH; + border-color: $medium-gray; + border-style: solid; + border-radius: $border-radius; + box-sizing: border-box; + position: relative; + top: 0; + width: 100%; + height: 100%; + margin-top: 0; + transition: top 0.5s; + display: flex; + justify-content: space-between; + flex-wrap: nowrap; + touch-action: none; + padding: 2px; + div { + touch-action: none; + } + .collectionSchemaView-tableContainer { + width: 100%; + height: 100%; + } + .collectionSchemaView-dividerDragger { + position: relative; + height: 100%; + width: 20px; + z-index: 20; + right: 0; + top: 0; + background: gray; + cursor: col-resize; + } + // .documentView-node:first-child { + // background: $white; + // } +} + +.ReactTable { + width: 100%; + background: white; + box-sizing: border-box; + border: none !important; + float: none !important; + .rt-table { + height: 100%; + display: -webkit-inline-box; + direction: ltr; + overflow: visible; + } + .rt-noData { + display: none; + } + .rt-thead { + width: 100%; + z-index: 100; + overflow-y: visible; + &.-header { + font-size: 12px; + height: 30px; + box-shadow: none; + z-index: 100; + overflow-y: visible; + } + .rt-resizable-header-content { + height: 100%; + overflow: visible; + } + .rt-th { + padding: 0; + border-left: solid 1px $light-gray; + } + } + .rt-th { + font-size: 13px; + text-align: center; + &:last-child { + overflow: visible; + } + } + .rt-tbody { + width: 100%; + direction: rtl; + overflow: visible; + .rt-td { + border-right: 1px solid rgba(0, 0, 0, 0.2); + } + } + .rt-tr-group { + direction: ltr; + flex: 0 1 auto; + min-height: 30px; + border: 0 !important; + } + .rt-tr-group:nth-of-type(even) { + direction: ltr; + flex: 0 1 auto; + min-height: 30px; + border: 0 !important; + background-color: red; + } + .rt-tr { + width: 100%; + min-height: 30px; + } + .rt-td { + padding: 0; + font-size: 13px; + text-align: center; + white-space: nowrap; + display: flex; + align-items: center; + .imageBox-cont { + position: relative; + max-height: 100%; + } + .imageBox-cont img { + object-fit: contain; + max-width: 100%; + height: 100%; + } + .videoBox-cont { + object-fit: contain; + width: auto; + height: 100%; + } + } + .rt-td.rt-expandable { + display: flex; + align-items: center; + height: inherit; + } + .rt-resizer { + width: 8px; + right: -4px; + } + .rt-resizable-header { + padding: 0; + height: 30px; + } + .rt-resizable-header:last-child { + overflow: visible; + .rt-resizer { + width: 5px !important; + } + } +} + +.documentView-node-topmost { + text-align: left; + transform-origin: center top; + display: inline-block; +} + +.collectionSchema-col { + height: 100%; +} + +.collectionSchema-header-menu { + height: auto; + z-index: 100; + position: absolute; + background: white; + padding: 5px; + position: fixed; + background: white; + border: black 1px solid; + .collectionSchema-header-toggler { + z-index: 100; + width: 100%; + height: 100%; + padding: 4px; + letter-spacing: 2px; + text-transform: uppercase; + svg { + margin-right: 4px; + } + } +} + +.collectionSchemaView-header { + height: 100%; + color: gray; + z-index: 100; + overflow-y: visible; + display: flex; + justify-content: space-between; + flex-wrap: wrap; +} + +button.add-column { + width: 28px; +} + +.collectionSchemaView-menuOptions-wrapper { + background: rgb(241, 239, 235); + display: flex; + cursor: default; + height: 100%; + align-content: center; + align-items: center; +} + +.collectionSchema-header-menuOptions { + color: black; + width: 180px; + text-align: left; + .collectionSchema-headerMenu-group { + padding: 7px 0; + border-bottom: 1px solid lightgray; + cursor: pointer; + &:first-child { + padding-top: 0; + } + &:last-child { + border: none; + text-align: center; + padding: 12px 0 0 0; + } + } + label { + color: $medium-gray; + font-weight: normal; + letter-spacing: 2px; + text-transform: uppercase; + } + input { + color: black; + width: 100%; + } + .columnMenu-option { + cursor: pointer; + padding: 3px; + background-color: white; + transition: background-color 0.2s; + &:hover { + background-color: $light-gray; + } + &.active { + font-weight: bold; + border: 2px solid $light-gray; + } + svg { + color: gray; + margin-right: 5px; + width: 10px; + } + } + + .keys-dropdown { + position: relative; + //width: 100%; + background-color: white; + input { + border: 2px solid $light-gray; + padding: 3px; + height: 28px; + font-weight: bold; + letter-spacing: '2px'; + text-transform: 'uppercase'; + &:focus { + font-weight: normal; + } + } + } + .columnMenu-colors { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + .columnMenu-colorPicker { + cursor: pointer; + width: 20px; + height: 20px; + border-radius: 10px; + &.active { + border: 2px solid white; + box-shadow: 0 0 0 2px lightgray; + } + } + } +} + +.schema-icon { + cursor: pointer; + width: 25px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + align-content: center; + background-color: $medium-blue; + color: white; + margin-right: 5px; + font-size: 10px; + border-radius: 3px; +} + +.keys-options-wrapper { + position: absolute; + text-align: left; + height: fit-content; + top: 100%; + z-index: 21; + background-color: #ffffff; + box-shadow: 0px 3px 4px rgba(0, 0, 0, 30%); + padding: 1px; + .key-option { + cursor: pointer; + color: #000000; + width: 100%; + height: 25px; + font-weight: 400; + display: flex; + justify-content: left; + align-items: center; + padding-left: 5px; + &:hover { + background-color: $light-gray; + } + } +} + +.collectionSchema-row { + height: 100%; + background-color: white; + &.row-focused .rt-td { + background-color: $light-blue; //$light-gray; + overflow: visible; + } + &.row-wrapped { + .rt-td { + white-space: normal; + } + } + .row-dragger { + display: flex; + justify-content: space-evenly; + width: 58px; + position: absolute; + /* max-width: 50px; */ + min-height: 30px; + align-items: center; + color: lightgray; + background-color: white; + transition: color 0.1s ease; + .row-option { + color: black; + cursor: pointer; + position: relative; + transition: color 0.1s ease; + display: flex; + flex-direction: column; + justify-content: center; + z-index: 2; + border-radius: 3px; + padding: 3px; + &:hover { + background-color: $light-gray; + } + } + } + .collectionSchema-row-wrapper { + &.row-above { + border-top: 1px solid $medium-blue; + } + &.row-below { + border-bottom: 1px solid $medium-blue; + } + &.row-inside { + border: 2px dashed $medium-blue; + } + .row-dragging { + background-color: blue; + } + } +} + +.collectionSchemaView-cellContainer { + width: 100%; + height: unset; +} + +.collectionSchemaView-cellContents { + width: 100%; +} + +.collectionSchemaView-cellWrapper { + display: flex; + height: 100%; + text-align: left; + padding-left: 19px; + position: relative; + align-items: center; + align-content: center; + &:focus { + outline: none; + } + &.editing { + padding: 0; + box-shadow: 0px 3px 4px rgba(0, 0, 0, 0.3); + transform: scale(1.1); + z-index: 40; + input { + outline: 0; + border: none; + background-color: $white; + width: 100%; + height: fit-content; + min-height: 26px; + } + } + &.focused { + overflow: hidden; + &.inactive { + border: none; + } + } + p { + width: 100%; + height: 100%; + } + &:hover .collectionSchemaView-cellContents-docExpander { + display: block; + } + .collectionSchemaView-cellContents-document { + display: inline-block; + } + .collectionSchemaView-cellContents-docButton { + float: right; + width: '15px'; + height: '15px'; + } + .collectionSchemaView-dropdownWrapper { + border: grey; + border-style: solid; + border-width: 1px; + height: 30px; + .collectionSchemaView-dropdownButton { + //display: inline-block; + float: left; + height: 100%; + } + .collectionSchemaView-dropdownText { + display: inline-block; + //float: right; + height: 100%; + display: 'flex'; + font-size: 13; + justify-content: 'center'; + align-items: 'center'; + } + } + .collectionSchemaView-dropdownContainer { + position: absolute; + border: 1px solid rgba(0, 0, 0, 0.04); + box-shadow: 0 16px 24px 2px rgba(0, 0, 0, 0.14); + .collectionSchemaView-dropdownOption:hover { + background-color: rgba(0, 0, 0, 0.14); + cursor: pointer; + } + } +} + +.collectionSchemaView-cellContents-docExpander { + height: 30px; + width: 30px; + display: none; + position: absolute; + top: 0; + right: 0; + background-color: lightgray; +} + +.doc-drag-over { + background-color: red; +} + +.collectionSchemaView-toolbar { + z-index: 100; +} + +.collectionSchemaView-toolbar { + height: 30px; + display: flex; + justify-content: flex-end; + padding: 0 10px; + border-bottom: 2px solid gray; + .collectionSchemaView-toolbar-item { + display: flex; + flex-direction: column; + justify-content: center; + } +} + +#preview-schema-checkbox-div { + margin-left: 20px; + font-size: 12px; +} + +.collectionSchemaView-table { + width: 100%; + height: 100%; + overflow: auto; + padding: 3px; +} + +.rt-td.rt-expandable { + overflow: visible; + position: relative; + height: 100%; + z-index: 1; +} + +.reactTable-sub { + background-color: rgb(252, 252, 252); + width: 100%; + .rt-thead { + display: none; + } + .row-dragger { + background-color: rgb(252, 252, 252); + } + .rt-table { + background-color: rgb(252, 252, 252); + } + .collectionSchemaView-table { + width: 100%; + border: solid 1px; + overflow: visible; + padding: 0px; + } +} + +.collectionSchemaView-expander { + height: 100%; + min-height: 30px; + position: absolute; + color: gray; + width: 20; + height: auto; + left: 55; + svg { + position: absolute; + top: 50%; + left: 10; + transform: translate(-50%, -50%); + } +} + +.collectionSchemaView-addRow { + color: gray; + letter-spacing: 2px; + text-transform: uppercase; + cursor: pointer; + font-size: 10.5px; + margin-left: 50px; + margin-top: 10px; +} diff --git a/src/client/views/collections/old_collectionSchema/OldCollectionSchemaView.tsx b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaView.tsx new file mode 100644 index 000000000..260db4b88 --- /dev/null +++ b/src/client/views/collections/old_collectionSchema/OldCollectionSchemaView.tsx @@ -0,0 +1,649 @@ +import React = require('react'); +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, computed, observable, untracked } from 'mobx'; +import { observer } from 'mobx-react'; +import Measure from 'react-measure'; +// import { Resize } from 'react-table'; +import { Doc, Opt } from '../../../../fields/Doc'; +import { List } from '../../../../fields/List'; +import { listSpec } from '../../../../fields/Schema'; +import { PastelSchemaPalette, SchemaHeaderField } from '../../../../fields/SchemaHeaderField'; +import { Cast, NumCast } from '../../../../fields/Types'; +import { TraceMobx } from '../../../../fields/util'; +import { emptyFunction, returnEmptyDoclist, returnFalse, returnTrue, setupMoveUpEvents } from '../../../../Utils'; +import { DocUtils } from '../../../documents/Documents'; +import { SelectionManager } from '../../../util/SelectionManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { Transform } from '../../../util/Transform'; +import { undoBatch } from '../../../util/UndoManager'; +import { ContextMenu } from '../../ContextMenu'; +import { ContextMenuProps } from '../../ContextMenuItem'; +import { COLLECTION_BORDER_WIDTH, SCHEMA_DIVIDER_WIDTH } from '../../global/globalCssVariables.scss'; +import { DocumentView } from '../../nodes/DocumentView'; +import { DefaultStyleProvider } from '../../StyleProvider'; +import { CollectionSubView } from '../CollectionSubView'; +import './CollectionSchemaView.scss'; +// import { SchemaTable } from './SchemaTable'; +// bcz: need to add drag and drop of rows and columns. This seems like it might work for rows: https://codesandbox.io/s/l94mn1q657 + +export enum ColumnType { + Any, + Number, + String, + Boolean, + Doc, + Image, + List, + Date, +} +// this map should be used for keys that should have a const type of value +const columnTypes: Map<string, ColumnType> = new Map([ + ['title', ColumnType.String], + ['x', ColumnType.Number], + ['y', ColumnType.Number], + ['_width', ColumnType.Number], + ['_height', ColumnType.Number], + ['_nativeWidth', ColumnType.Number], + ['_nativeHeight', ColumnType.Number], + ['isPrototype', ColumnType.Boolean], + ['_curPage', ColumnType.Number], + ['_currentTimecode', ColumnType.Number], + ['zIndex', ColumnType.Number], +]); + +@observer +export class CollectionSchemaView extends CollectionSubView() { + private _previewCont?: HTMLDivElement; + + @observable _previewDoc: Doc | undefined = undefined; + @observable _focusedTable: Doc = this.props.Document; + @observable _col: any = ''; + @observable _menuWidth = 0; + @observable _headerOpen = false; + @observable _headerIsEditing = false; + @observable _menuHeight = 0; + @observable _pointerX = 0; + @observable _pointerY = 0; + @observable _openTypes: boolean = false; + + @computed get previewWidth() { + return () => NumCast(this.props.Document.schemaPreviewWidth); + } + @computed get previewHeight() { + return () => this.props.PanelHeight() - 2 * this.borderWidth; + } + @computed get tableWidth() { + return this.props.PanelWidth() - 2 * this.borderWidth - Number(SCHEMA_DIVIDER_WIDTH) - this.previewWidth(); + } + @computed get borderWidth() { + return Number(COLLECTION_BORDER_WIDTH); + } + @computed get scale() { + return this.props.ScreenToLocalTransform().Scale; + } + @computed get columns() { + return Cast(this.props.Document._schemaHeaders, listSpec(SchemaHeaderField), []); + } + set columns(columns: SchemaHeaderField[]) { + this.props.Document._schemaHeaders = new List<SchemaHeaderField>(columns); + } + + @computed get menuCoordinates() { + let searchx = 0; + let searchy = 0; + if (this.props.Document._searchDoc) { + const el = document.getElementsByClassName('collectionSchemaView-searchContainer')[0]; + if (el !== undefined) { + const rect = el.getBoundingClientRect(); + searchx = rect.x; + searchy = rect.y; + } + } + const x = Math.max(0, Math.min(document.body.clientWidth - this._menuWidth, this._pointerX)) - searchx; + const y = Math.max(0, Math.min(document.body.clientHeight - this._menuHeight, this._pointerY)) - searchy; + return this.props.ScreenToLocalTransform().transformPoint(x, y); + } + + get documentKeys() { + const docs = this.childDocs; + const keys: { [key: string]: boolean } = {}; + // bcz: ugh. this is untracked since otherwise a large collection of documents will blast the server for all their fields. + // then as each document's fields come back, we update the documents _proxies. Each time we do this, the whole schema will be + // invalidated and re-rendered. This workaround will inquire all of the document fields before the options button is clicked. + // then by the time the options button is clicked, all of the fields should be in place. If a new field is added while this menu + // is displayed (unlikely) it won't show up until something else changes. + //TODO Types + untracked(() => docs.map(doc => Doc.GetAllPrototypes(doc).map(proto => Object.keys(proto).forEach(key => (keys[key] = false))))); + + this.columns.forEach(key => (keys[key.heading] = true)); + return Array.from(Object.keys(keys)); + } + + @action setHeaderIsEditing = (isEditing: boolean) => (this._headerIsEditing = isEditing); + + @undoBatch + setColumnType = action((columnField: SchemaHeaderField, type: ColumnType): void => { + this._openTypes = false; + if (columnTypes.get(columnField.heading)) return; + + const columns = this.columns; + const index = columns.indexOf(columnField); + if (index > -1) { + columnField.setType(NumCast(type)); + columns[index] = columnField; + this.columns = columns; + } + }); + + @undoBatch + setColumnColor = (columnField: SchemaHeaderField, color: string): void => { + const columns = this.columns; + const index = columns.indexOf(columnField); + if (index > -1) { + columnField.setColor(color); + columns[index] = columnField; + this.columns = columns; // need to set the columns to trigger rerender + } + }; + + @undoBatch + @action + setColumnSort = (columnField: SchemaHeaderField, descending: boolean | undefined) => { + const columns = this.columns; + columns.forEach(col => col.setDesc(undefined)); + + const index = columns.findIndex(c => c.heading === columnField.heading); + const column = columns[index]; + column.setDesc(descending); + columns[index] = column; + this.columns = columns; + }; + + renderTypes = (col: any) => { + if (columnTypes.get(col.heading)) return null; + + const type = col.type; + + const anyType = ( + <div className={'columnMenu-option' + (type === ColumnType.Any ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.Any)}> + <FontAwesomeIcon icon={'align-justify'} size="sm" /> + Any + </div> + ); + + const numType = ( + <div className={'columnMenu-option' + (type === ColumnType.Number ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.Number)}> + <FontAwesomeIcon icon={'hashtag'} size="sm" /> + Number + </div> + ); + + const textType = ( + <div className={'columnMenu-option' + (type === ColumnType.String ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.String)}> + <FontAwesomeIcon icon={'font'} size="sm" /> + Text + </div> + ); + + const boolType = ( + <div className={'columnMenu-option' + (type === ColumnType.Boolean ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.Boolean)}> + <FontAwesomeIcon icon={'check-square'} size="sm" /> + Checkbox + </div> + ); + + const listType = ( + <div className={'columnMenu-option' + (type === ColumnType.List ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.List)}> + <FontAwesomeIcon icon={'list-ul'} size="sm" /> + List + </div> + ); + + const docType = ( + <div className={'columnMenu-option' + (type === ColumnType.Doc ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.Doc)}> + <FontAwesomeIcon icon={'file'} size="sm" /> + Document + </div> + ); + + const imageType = ( + <div className={'columnMenu-option' + (type === ColumnType.Image ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.Image)}> + <FontAwesomeIcon icon={'image'} size="sm" /> + Image + </div> + ); + + const dateType = ( + <div className={'columnMenu-option' + (type === ColumnType.Date ? ' active' : '')} onClick={() => this.setColumnType(col, ColumnType.Date)}> + <FontAwesomeIcon icon={'calendar'} size="sm" /> + Date + </div> + ); + + const allColumnTypes = ( + <div className="columnMenu-types"> + {anyType} + {numType} + {textType} + {boolType} + {listType} + {docType} + {imageType} + {dateType} + </div> + ); + + const justColType = + type === ColumnType.Any + ? anyType + : type === ColumnType.Number + ? numType + : type === ColumnType.String + ? textType + : type === ColumnType.Boolean + ? boolType + : type === ColumnType.List + ? listType + : type === ColumnType.Doc + ? docType + : type === ColumnType.Date + ? dateType + : imageType; + + return ( + <div className="collectionSchema-headerMenu-group" onClick={action(() => (this._openTypes = !this._openTypes))}> + <div> + <label style={{ cursor: 'pointer' }}>Column type:</label> + <FontAwesomeIcon icon={'caret-down'} size="lg" style={{ float: 'right', transform: `rotate(${this._openTypes ? '180deg' : 0})`, transition: '0.2s all ease' }} /> + </div> + {this._openTypes ? allColumnTypes : justColType} + </div> + ); + }; + + renderSorting = (col: any) => { + const sort = col.desc; + return ( + <div className="collectionSchema-headerMenu-group"> + <label>Sort by:</label> + <div className="columnMenu-sort"> + <div className={'columnMenu-option' + (sort === true ? ' active' : '')} onClick={() => this.setColumnSort(col, true)}> + <FontAwesomeIcon icon="sort-amount-down" size="sm" /> + Sort descending + </div> + <div className={'columnMenu-option' + (sort === false ? ' active' : '')} onClick={() => this.setColumnSort(col, false)}> + <FontAwesomeIcon icon="sort-amount-up" size="sm" /> + Sort ascending + </div> + <div className="columnMenu-option" onClick={() => this.setColumnSort(col, undefined)}> + <FontAwesomeIcon icon="times" size="sm" /> + Clear sorting + </div> + </div> + </div> + ); + }; + + renderColors = (col: any) => { + const selected = col.color; + + const pink = PastelSchemaPalette.get('pink2'); + const purple = PastelSchemaPalette.get('purple2'); + const blue = PastelSchemaPalette.get('bluegreen1'); + const yellow = PastelSchemaPalette.get('yellow4'); + const red = PastelSchemaPalette.get('red2'); + const gray = '#f1efeb'; + + return ( + <div className="collectionSchema-headerMenu-group"> + <label>Color:</label> + <div className="columnMenu-colors"> + <div className={'columnMenu-colorPicker' + (selected === pink ? ' active' : '')} style={{ backgroundColor: pink }} onClick={() => this.setColumnColor(col, pink!)}></div> + <div className={'columnMenu-colorPicker' + (selected === purple ? ' active' : '')} style={{ backgroundColor: purple }} onClick={() => this.setColumnColor(col, purple!)}></div> + <div className={'columnMenu-colorPicker' + (selected === blue ? ' active' : '')} style={{ backgroundColor: blue }} onClick={() => this.setColumnColor(col, blue!)}></div> + <div className={'columnMenu-colorPicker' + (selected === yellow ? ' active' : '')} style={{ backgroundColor: yellow }} onClick={() => this.setColumnColor(col, yellow!)}></div> + <div className={'columnMenu-colorPicker' + (selected === red ? ' active' : '')} style={{ backgroundColor: red }} onClick={() => this.setColumnColor(col, red!)}></div> + <div className={'columnMenu-colorPicker' + (selected === gray ? ' active' : '')} style={{ backgroundColor: gray }} onClick={() => this.setColumnColor(col, gray)}></div> + </div> + </div> + ); + }; + + @undoBatch + @action + changeColumns = (oldKey: string, newKey: string, addNew: boolean, filter?: string) => { + const columns = this.columns; + if (columns === undefined) { + this.columns = new List<SchemaHeaderField>([new SchemaHeaderField(newKey, 'f1efeb')]); + } else { + if (addNew) { + columns.push(new SchemaHeaderField(newKey, 'f1efeb')); + this.columns = columns; + } else { + const index = columns.map(c => c.heading).indexOf(oldKey); + if (index > -1) { + const column = columns[index]; + column.setHeading(newKey); + columns[index] = column; + this.columns = columns; + if (filter) { + Doc.setDocFilter(this.props.Document, newKey, filter, 'match'); + } else { + this.props.Document._docFilters = undefined; + } + } + } + } + }; + + @action + openHeader = (col: any, screenx: number, screeny: number) => { + this._col = col; + this._headerOpen = true; + this._pointerX = screenx; + this._pointerY = screeny; + }; + + @action + closeHeader = () => { + this._headerOpen = false; + }; + + @undoBatch + @action + deleteColumn = (key: string) => { + const columns = this.columns; + if (columns === undefined) { + this.columns = new List<SchemaHeaderField>([]); + } else { + const index = columns.map(c => c.heading).indexOf(key); + if (index > -1) { + columns.splice(index, 1); + this.columns = columns; + } + } + this.closeHeader(); + }; + + getPreviewTransform = (): Transform => { + return this.props.ScreenToLocalTransform().translate(-this.borderWidth - NumCast(COLLECTION_BORDER_WIDTH) - this.tableWidth, -this.borderWidth); + }; + + @action + onHeaderClick = (e: React.PointerEvent) => { + e.stopPropagation(); + }; + + @action + onWheel(e: React.WheelEvent) { + const scale = this.props.ScreenToLocalTransform().Scale; + this.props.isContentActive(true) && e.stopPropagation(); + } + + @computed get renderMenuContent() { + TraceMobx(); + return ( + <div className="collectionSchema-header-menuOptions"> + {this.renderTypes(this._col)} + {this.renderColors(this._col)} + <div className="collectionSchema-headerMenu-group"> + <button + onClick={() => { + this.deleteColumn(this._col.heading); + }}> + Hide Column + </button> + </div> + </div> + ); + } + + private createTarget = (ele: HTMLDivElement) => { + this._previewCont = ele; + super.CreateDropTarget(ele); + }; + + isFocused = (doc: Doc, outsideReaction: boolean): boolean => this.props.isSelected(outsideReaction) && doc === this._focusedTable; + + @action setFocused = (doc: Doc) => (this._focusedTable = doc); + + @action setPreviewDoc = (doc: Opt<Doc>) => { + SelectionManager.SelectSchemaViewDoc(doc); + this._previewDoc = doc; + }; + + //toggles preview side-panel of schema + @action + toggleExpander = () => { + this.props.Document.schemaPreviewWidth = this.previewWidth() === 0 ? Math.min(this.tableWidth / 3, 200) : 0; + }; + + onDividerDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, this.onDividerMove, emptyFunction, this.toggleExpander); + }; + @action + onDividerMove = (e: PointerEvent, down: number[], delta: number[]) => { + const nativeWidth = this._previewCont!.getBoundingClientRect(); + const minWidth = 40; + const maxWidth = 1000; + const movedWidth = this.props.ScreenToLocalTransform().transformDirection(nativeWidth.right - e.clientX, 0)[0]; + const width = movedWidth < minWidth ? minWidth : movedWidth > maxWidth ? maxWidth : movedWidth; + this.props.Document.schemaPreviewWidth = width; + return false; + }; + + onPointerDown = (e: React.PointerEvent): void => { + if (e.button === 0 && !e.altKey && !e.ctrlKey && !e.metaKey) { + if (this.props.isSelected(true)) e.stopPropagation(); + else this.props.select(false); + } + }; + + @computed + get previewDocument(): Doc | undefined { + return this._previewDoc; + } + + @computed + get dividerDragger() { + return this.previewWidth() === 0 ? null : ( + <div className="collectionSchemaView-dividerDragger" onPointerDown={this.onDividerDown}> + <div className="collectionSchemaView-dividerDragger" /> + </div> + ); + } + + @computed + get previewPanel() { + return ( + <div ref={this.createTarget} style={{ width: `${this.previewWidth()}px` }}> + {!this.previewDocument ? null : ( + <DocumentView + Document={this.previewDocument} + DataDoc={undefined} + fitContentsToBox={returnTrue} + dontCenter={'y'} + focus={DocUtils.DefaultFocus} + renderDepth={this.props.renderDepth} + rootSelected={this.rootSelected} + PanelWidth={this.previewWidth} + PanelHeight={this.previewHeight} + isContentActive={returnTrue} + isDocumentActive={returnFalse} + ScreenToLocalTransform={this.getPreviewTransform} + docFilters={this.childDocFilters} + docRangeFilters={this.childDocRangeFilters} + searchFilterDocs={this.searchFilterDocs} + styleProvider={DefaultStyleProvider} + docViewPath={returnEmptyDoclist} + ContainingCollectionDoc={this.props.CollectionView?.props.Document} + ContainingCollectionView={this.props.CollectionView} + moveDocument={this.props.moveDocument} + addDocument={this.props.addDocument} + removeDocument={this.props.removeDocument} + whenChildContentsActiveChanged={this.props.whenChildContentsActiveChanged} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + bringToFront={returnFalse} + /> + )} + </div> + ); + } + + @computed + get schemaTable() { + return ( + <SchemaTable + Document={this.props.Document} + PanelHeight={this.props.PanelHeight} + PanelWidth={this.props.PanelWidth} + childDocs={this.childDocs} + CollectionView={this.props.CollectionView} + ContainingCollectionView={this.props.ContainingCollectionView} + ContainingCollectionDoc={this.props.ContainingCollectionDoc} + fieldKey={this.props.fieldKey} + renderDepth={this.props.renderDepth} + moveDocument={this.props.moveDocument} + ScreenToLocalTransform={this.props.ScreenToLocalTransform} + active={this.props.isContentActive} + onDrop={this.onExternalDrop} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + isSelected={this.props.isSelected} + isFocused={this.isFocused} + setFocused={this.setFocused} + setPreviewDoc={this.setPreviewDoc} + deleteDocument={this.props.removeDocument} + addDocument={this.props.addDocument} + dataDoc={this.props.DataDoc} + columns={this.columns} + documentKeys={this.documentKeys} + headerIsEditing={this._headerIsEditing} + openHeader={this.openHeader} + onClick={this.onTableClick} + onPointerDown={emptyFunction} + onResizedChange={this.onResizedChange} + setColumns={this.setColumns} + reorderColumns={this.reorderColumns} + changeColumns={this.changeColumns} + setHeaderIsEditing={this.setHeaderIsEditing} + changeColumnSort={this.setColumnSort} + /> + ); + } + + @computed + public get schemaToolbar() { + return ( + <div className="collectionSchemaView-toolbar"> + <div className="collectionSchemaView-toolbar-item"> + <div id="preview-schema-checkbox-div"> + <input type="checkbox" key={'Show Preview'} checked={this.previewWidth() !== 0} onChange={this.toggleExpander} /> + Show Preview + </div> + </div> + </div> + ); + } + + onSpecificMenu = (e: React.MouseEvent) => { + if ((e.target as any)?.className?.includes?.('collectionSchemaView-cell') || e.target instanceof HTMLSpanElement) { + const cm = ContextMenu.Instance; + const options = cm.findByDescription('Options...'); + const optionItems: ContextMenuProps[] = options && 'subitems' in options ? options.subitems : []; + optionItems.push({ description: 'remove', event: () => this._previewDoc && this.props.removeDocument?.(this._previewDoc), icon: 'trash' }); + !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'compass' }); + cm.displayMenu(e.clientX, e.clientY); + (e.nativeEvent as any).SchemaHandled = true; // not sure why this is needed, but if you right-click quickly on a cell, the Document/Collection contextMenu handlers still fire without this. + e.stopPropagation(); + } + }; + + @action + onTableClick = (e: React.MouseEvent): void => { + if (!(e.target as any)?.className?.includes?.('collectionSchemaView-cell') && !(e.target instanceof HTMLSpanElement)) { + this.setPreviewDoc(undefined); + } else { + e.stopPropagation(); + } + this.setFocused(this.props.Document); + this.closeHeader(); + }; + + onResizedChange = (newResized: Resize[], event: any) => { + const columns = this.columns; + newResized.forEach(resized => { + const index = columns.findIndex(c => c.heading === resized.id); + const column = columns[index]; + column.setWidth(resized.value); + columns[index] = column; + }); + this.columns = columns; + }; + + @action + setColumns = (columns: SchemaHeaderField[]) => (this.columns = columns); + + @undoBatch + reorderColumns = (toMove: SchemaHeaderField, relativeTo: SchemaHeaderField, before: boolean, columnsValues: SchemaHeaderField[]) => { + const columns = [...columnsValues]; + const oldIndex = columns.indexOf(toMove); + const relIndex = columns.indexOf(relativeTo); + const newIndex = oldIndex > relIndex && !before ? relIndex + 1 : oldIndex < relIndex && before ? relIndex - 1 : relIndex; + + if (oldIndex === newIndex) return; + + columns.splice(newIndex, 0, columns.splice(oldIndex, 1)[0]); + this.columns = columns; + }; + + onZoomMenu = (e: React.WheelEvent) => this.props.isContentActive(true) && e.stopPropagation(); + + render() { + TraceMobx(); + if (!this.props.isContentActive()) setTimeout(() => this.closeHeader(), 0); + const menuContent = this.renderMenuContent; + const menu = ( + <div className="collectionSchema-header-menu" onWheel={e => this.onZoomMenu(e)} onPointerDown={e => this.onHeaderClick(e)} style={{ transform: `translate(${this.menuCoordinates[0]}px, ${this.menuCoordinates[1]}px)` }}> + <Measure + offset + onResize={action((r: any) => { + const dim = this.props.ScreenToLocalTransform().inverse().transformDirection(r.offset.width, r.offset.height); + this._menuWidth = dim[0]; + this._menuHeight = dim[1]; + })}> + {({ measureRef }) => <div ref={measureRef}> {menuContent} </div>} + </Measure> + </div> + ); + return ( + <div + className={'collectionSchemaView' + (this.props.Document._searchDoc ? '-searchContainer' : '-container')} + style={{ + overflow: this.props.scrollOverflow === true ? 'scroll' : undefined, + backgroundColor: 'white', + pointerEvents: this.props.Document._searchDoc !== undefined && !this.props.isContentActive() && !SnappingManager.GetIsDragging() ? 'none' : undefined, + width: this.props.PanelWidth() || '100%', + height: this.props.PanelHeight() || '100%', + position: 'relative', + }}> + <div + className="collectionSchemaView-tableContainer" + style={{ width: `calc(100% - ${this.previewWidth()}px)` }} + onContextMenu={this.onSpecificMenu} + onPointerDown={this.onPointerDown} + onWheel={e => this.props.isContentActive(true) && e.stopPropagation()} + onDrop={e => this.onExternalDrop(e, {})} + ref={this.createTarget}> + {this.schemaTable} + </div> + {this.dividerDragger} + {!this.previewWidth() ? null : this.previewPanel} + {this._headerOpen && this.props.isContentActive() ? menu : null} + </div> + ); + TraceMobx(); + return <div>HELLO</div>; + } +} diff --git a/src/client/views/collections/old_collectionSchema/OldSchemaTable.tsx b/src/client/views/collections/old_collectionSchema/OldSchemaTable.tsx new file mode 100644 index 000000000..dfeee3173 --- /dev/null +++ b/src/client/views/collections/old_collectionSchema/OldSchemaTable.tsx @@ -0,0 +1,693 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, computed, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import ReactTable, { CellInfo, Column, ComponentPropsGetterR, Resize, SortingRule } from 'react-table'; +import { DateField } from '../../../../fields/DateField'; +import { AclPrivate, AclReadonly, DataSym, Doc, DocListCast, Field, Opt } from '../../../../fields/Doc'; +import { Id } from '../../../../fields/FieldSymbols'; +import { List } from '../../../../fields/List'; +import { listSpec } from '../../../../fields/Schema'; +import { SchemaHeaderField } from '../../../../fields/SchemaHeaderField'; +import { ComputedField } from '../../../../fields/ScriptField'; +import { Cast, FieldValue, NumCast, StrCast } from '../../../../fields/Types'; +import { ImageField } from '../../../../fields/URLField'; +import { GetEffectiveAcl } from '../../../../fields/util'; +import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue } from '../../../../Utils'; +import { Docs, DocumentOptions, DocUtils } from '../../../documents/Documents'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { CompileScript, Transformer, ts } from '../../../util/Scripting'; +import { Transform } from '../../../util/Transform'; +import { undoBatch } from '../../../util/UndoManager'; +import '../../../views/DocumentDecorations.scss'; +import { ContextMenu } from '../../ContextMenu'; +import { COLLECTION_BORDER_WIDTH, SCHEMA_DIVIDER_WIDTH } from '../../global/globalCssVariables.scss'; +import { DocumentView } from '../../nodes/DocumentView'; +import { DefaultStyleProvider } from '../../StyleProvider'; +import { CollectionView } from '../CollectionView'; +import { + CellProps, + CollectionSchemaButtons, + CollectionSchemaCell, + CollectionSchemaCheckboxCell, + CollectionSchemaDateCell, + CollectionSchemaDocCell, + CollectionSchemaImageCell, + CollectionSchemaListCell, + CollectionSchemaNumberCell, + CollectionSchemaStringCell, +} from './CollectionSchemaCells'; +import { CollectionSchemaAddColumnHeader, KeysDropdown } from './CollectionSchemaHeaders'; +import { MovableColumn } from './OldCollectionSchemaMovableColumn'; +import { MovableRow } from './CollectionSchemaMovableRow'; +import './CollectionSchemaView.scss'; + +enum ColumnType { + Any, + Number, + String, + Boolean, + Doc, + Image, + List, + Date, +} + +// this map should be used for keys that should have a const type of value +const columnTypes: Map<string, ColumnType> = new Map([ + ['title', ColumnType.String], + ['x', ColumnType.Number], + ['y', ColumnType.Number], + ['_width', ColumnType.Number], + ['_height', ColumnType.Number], + ['_nativeWidth', ColumnType.Number], + ['_nativeHeight', ColumnType.Number], + ['isPrototype', ColumnType.Boolean], + ['_curPage', ColumnType.Number], + ['_currentTimecode', ColumnType.Number], + ['zIndex', ColumnType.Number], +]); + +export interface SchemaTableProps { + Document: Doc; // child doc + dataDoc?: Doc; + PanelHeight: () => number; + PanelWidth: () => number; + childDocs?: Doc[]; + CollectionView: Opt<CollectionView>; + ContainingCollectionView: Opt<CollectionView>; + ContainingCollectionDoc: Opt<Doc>; + fieldKey: string; + renderDepth: number; + deleteDocument?: (document: Doc | Doc[]) => boolean; + addDocument?: (document: Doc | Doc[]) => boolean; + moveDocument?: (document: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; + ScreenToLocalTransform: () => Transform; + active: (outsideReaction: boolean | undefined) => boolean | undefined; + onDrop: (e: React.DragEvent<Element>, options: DocumentOptions, completed?: (() => void) | undefined) => void; + addDocTab: (document: Doc, where: string) => boolean; + pinToPres: (document: Doc) => void; + isSelected: (outsideReaction?: boolean) => boolean; + isFocused: (document: Doc, outsideReaction: boolean) => boolean; + setFocused: (document: Doc) => void; + setPreviewDoc: (document: Opt<Doc>) => void; + columns: SchemaHeaderField[]; + documentKeys: any[]; + headerIsEditing: boolean; + openHeader: (column: any, screenx: number, screeny: number) => void; + onClick: (e: React.MouseEvent) => void; + onPointerDown: (e: React.PointerEvent) => void; + onResizedChange: (newResized: Resize[], event: any) => void; + setColumns: (columns: SchemaHeaderField[]) => void; + reorderColumns: (toMove: SchemaHeaderField, relativeTo: SchemaHeaderField, before: boolean, columnsValues: SchemaHeaderField[]) => void; + changeColumns: (oldKey: string, newKey: string, addNew: boolean) => void; + setHeaderIsEditing: (isEditing: boolean) => void; + changeColumnSort: (columnField: SchemaHeaderField, descending: boolean | undefined) => void; +} + +@observer +export class SchemaTable extends React.Component<SchemaTableProps> { + @observable _cellIsEditing: boolean = false; + @observable _focusedCell: { row: number; col: number } = { row: 0, col: 0 }; + @observable _openCollections: Set<number> = new Set(); + + @observable _showDoc: Doc | undefined; + @observable _showDataDoc: any = ''; + @observable _showDocPos: number[] = []; + + @observable _showTitleDropdown: boolean = false; + + @computed get previewWidth() { + return () => NumCast(this.props.Document.schemaPreviewWidth); + } + @computed get previewHeight() { + return () => this.props.PanelHeight() - 2 * this.borderWidth; + } + @computed get tableWidth() { + return this.props.PanelWidth() - 2 * this.borderWidth - Number(SCHEMA_DIVIDER_WIDTH) - this.previewWidth(); + } + + @computed get childDocs() { + if (this.props.childDocs) return this.props.childDocs; + + const doc = this.props.dataDoc ? this.props.dataDoc : this.props.Document; + return DocListCast(doc[this.props.fieldKey]); + } + set childDocs(docs: Doc[]) { + const doc = this.props.dataDoc ? this.props.dataDoc : this.props.Document; + doc[this.props.fieldKey] = new List<Doc>(docs); + } + + @computed get textWrappedRows() { + return Cast(this.props.Document.textwrappedSchemaRows, listSpec('string'), []); + } + set textWrappedRows(textWrappedRows: string[]) { + this.props.Document.textwrappedSchemaRows = new List<string>(textWrappedRows); + } + + @computed get resized(): { id: string; value: number }[] { + return this.props.columns.reduce((resized, shf) => { + shf.width > -1 && resized.push({ id: shf.heading, value: shf.width }); + return resized; + }, [] as { id: string; value: number }[]); + } + @computed get sorted(): SortingRule[] { + return this.props.columns.reduce((sorted, shf) => { + shf.desc !== undefined && sorted.push({ id: shf.heading, desc: shf.desc }); + return sorted; + }, [] as SortingRule[]); + } + + @action + changeSorting = (col: any) => { + this.props.changeColumnSort(col, col.desc === true ? false : col.desc === false ? undefined : true); + }; + + @action + changeTitleMode = () => (this._showTitleDropdown = !this._showTitleDropdown); + + @computed get borderWidth() { + return Number(COLLECTION_BORDER_WIDTH); + } + @computed get tableColumns(): Column<Doc>[] { + const possibleKeys = this.props.documentKeys.filter(key => this.props.columns.findIndex(existingKey => existingKey.heading.toUpperCase() === key.toUpperCase()) === -1); + const columns: Column<Doc>[] = []; + const tableIsFocused = this.props.isFocused(this.props.Document, false); + const focusedRow = this._focusedCell.row; + const focusedCol = this._focusedCell.col; + const isEditable = !this.props.headerIsEditing; + + columns.push({ + expander: true, + Header: '', + width: 58, + Expander: rowInfo => { + return rowInfo.original.type !== DocumentType.COL ? null : ( + <div className="collectionSchemaView-expander" onClick={action(() => this._openCollections[rowInfo.isExpanded ? 'delete' : 'add'](rowInfo.viewIndex))}> + <FontAwesomeIcon icon={rowInfo.isExpanded ? 'caret-down' : 'caret-right'} size="lg" /> + </div> + ); + }, + }); + columns.push( + ...this.props.columns.map(col => { + const icon: IconProp = + this.getColumnType(col) === ColumnType.Number + ? 'hashtag' + : this.getColumnType(col) === ColumnType.String + ? 'font' + : this.getColumnType(col) === ColumnType.Boolean + ? 'check-square' + : this.getColumnType(col) === ColumnType.Doc + ? 'file' + : this.getColumnType(col) === ColumnType.Image + ? 'image' + : this.getColumnType(col) === ColumnType.List + ? 'list-ul' + : this.getColumnType(col) === ColumnType.Date + ? 'calendar' + : 'align-justify'; + + const keysDropdown = ( + <KeysDropdown + keyValue={col.heading} + possibleKeys={possibleKeys} + existingKeys={this.props.columns.map(c => c.heading)} + canAddNew={true} + addNew={false} + onSelect={this.props.changeColumns} + setIsEditing={this.props.setHeaderIsEditing} + docs={this.props.childDocs} + Document={this.props.Document} + dataDoc={this.props.dataDoc} + fieldKey={this.props.fieldKey} + ContainingCollectionDoc={this.props.ContainingCollectionDoc} + ContainingCollectionView={this.props.ContainingCollectionView} + active={this.props.active} + openHeader={this.props.openHeader} + icon={icon} + col={col} + // try commenting this out + width={'100%'} + /> + ); + + const sortIcon = col.desc === undefined ? 'caret-right' : col.desc === true ? 'caret-down' : 'caret-up'; + const header = ( + <div className="collectionSchemaView-menuOptions-wrapper" style={{ background: col.color, padding: '2px', display: 'flex', cursor: 'default', height: '100%' }}> + {keysDropdown} + <div onClick={e => this.changeSorting(col)} style={{ width: 21, padding: 1, display: 'inline', zIndex: 1, background: 'inherit', cursor: 'pointer' }}> + <FontAwesomeIcon icon={sortIcon} size="lg" /> + </div> + {/* {this.props.Document._chromeHidden || this.props.addDocument == returnFalse ? undefined : <div className="collectionSchemaView-addRow" onClick={this.createRow}>+ new</div>} */} + </div> + ); + + return { + Header: <MovableColumn columnRenderer={header} columnValue={col} allColumns={this.props.columns} reorderColumns={this.props.reorderColumns} ScreenToLocalTransform={this.props.ScreenToLocalTransform} />, + accessor: (doc: Doc) => (doc ? Field.toString(doc[col.heading] as Field) : 0), + id: col.heading, + Cell: (rowProps: CellInfo) => { + const rowIndex = rowProps.index; + const columnIndex = this.props.columns.map(c => c.heading).indexOf(rowProps.column.id!); + const isFocused = focusedRow === rowIndex && focusedCol === columnIndex && tableIsFocused; + + const props: CellProps = { + row: rowIndex, + col: columnIndex, + rowProps: rowProps, + isFocused: isFocused, + changeFocusedCellByIndex: this.changeFocusedCellByIndex, + CollectionView: this.props.CollectionView, + ContainingCollection: this.props.ContainingCollectionView, + Document: this.props.Document, + fieldKey: this.props.fieldKey, + renderDepth: this.props.renderDepth, + addDocTab: this.props.addDocTab, + pinToPres: this.props.pinToPres, + moveDocument: this.props.moveDocument, + setIsEditing: this.setCellIsEditing, + isEditable: isEditable, + setPreviewDoc: this.props.setPreviewDoc, + setComputed: this.setComputed, + getField: this.getField, + showDoc: this.showDoc, + }; + + switch (this.getColumnType(col, rowProps.original, rowProps.column.id)) { + case ColumnType.Number: + return <CollectionSchemaNumberCell {...props} />; + case ColumnType.String: + return <CollectionSchemaStringCell {...props} />; + case ColumnType.Boolean: + return <CollectionSchemaCheckboxCell {...props} />; + case ColumnType.Doc: + return <CollectionSchemaDocCell {...props} />; + case ColumnType.Image: + return <CollectionSchemaImageCell {...props} />; + case ColumnType.List: + return <CollectionSchemaListCell {...props} />; + case ColumnType.Date: + return <CollectionSchemaDateCell {...props} />; + default: + return <CollectionSchemaCell {...props} />; + } + }, + minWidth: 200, + }; + }) + ); + columns.push({ + Header: <CollectionSchemaAddColumnHeader createColumn={this.createColumn} />, + accessor: (doc: Doc) => 0, + id: 'add', + Cell: (rowProps: CellInfo) => { + const rowIndex = rowProps.index; + const columnIndex = this.props.columns.map(c => c.heading).indexOf(rowProps.column.id!); + const isFocused = focusedRow === rowIndex && focusedCol === columnIndex && tableIsFocused; + return ( + <CollectionSchemaButtons + {...{ + row: rowProps.index, + col: columnIndex, + rowProps: rowProps, + isFocused: isFocused, + changeFocusedCellByIndex: this.changeFocusedCellByIndex, + CollectionView: this.props.CollectionView, + ContainingCollection: this.props.ContainingCollectionView, + Document: this.props.Document, + fieldKey: this.props.fieldKey, + renderDepth: this.props.renderDepth, + addDocTab: this.props.addDocTab, + pinToPres: this.props.pinToPres, + moveDocument: this.props.moveDocument, + setIsEditing: this.setCellIsEditing, + isEditable: isEditable, + setPreviewDoc: this.props.setPreviewDoc, + setComputed: this.setComputed, + getField: this.getField, + showDoc: this.showDoc, + }} + /> + ); + }, + width: 28, + resizable: false, + }); + return columns; + } + + constructor(props: SchemaTableProps) { + super(props); + if (this.props.Document._schemaHeaders === undefined) { + this.props.Document._schemaHeaders = new List<SchemaHeaderField>([ + new SchemaHeaderField('title', '#f1efeb'), + new SchemaHeaderField('author', '#f1efeb'), + new SchemaHeaderField('*lastModified', '#f1efeb', ColumnType.Date), + new SchemaHeaderField('text', '#f1efeb', ColumnType.String), + new SchemaHeaderField('type', '#f1efeb'), + new SchemaHeaderField('context', '#f1efeb', ColumnType.Doc), + ]); + } + } + + componentDidMount() { + document.addEventListener('keydown', this.onKeyDown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onKeyDown); + } + + tableAddDoc = (doc: Doc, relativeTo?: Doc, before?: boolean) => { + const tableDoc = this.props.Document[DataSym]; + const effectiveAcl = GetEffectiveAcl(tableDoc); + + if (effectiveAcl !== AclPrivate && effectiveAcl !== AclReadonly) { + doc.context = this.props.Document; + tableDoc[this.props.fieldKey + '-lastModified'] = new DateField(new Date(Date.now())); + return Doc.AddDocToList(this.props.Document, this.props.fieldKey, doc, relativeTo, before); + } + return false; + }; + + private getTrProps: ComponentPropsGetterR = (state, rowInfo) => { + return !rowInfo + ? {} + : { + ScreenToLocalTransform: this.props.ScreenToLocalTransform, + addDoc: this.tableAddDoc, + removeDoc: this.props.deleteDocument, + rowInfo, + rowFocused: !this.props.headerIsEditing && rowInfo.index === this._focusedCell.row && this.props.isFocused(this.props.Document, true), + textWrapRow: this.toggleTextWrapRow, + rowWrapped: this.textWrappedRows.findIndex(id => rowInfo.original[Id] === id) > -1, + dropAction: StrCast(this.props.Document.childDropAction), + addDocTab: this.props.addDocTab, + }; + }; + + private getTdProps: ComponentPropsGetterR = (state, rowInfo, column, instance) => { + if (!rowInfo || column) return {}; + + const row = rowInfo.index; + //@ts-ignore + const col = this.columns.map(c => c.heading).indexOf(column!.id); + const isFocused = this._focusedCell.row === row && this._focusedCell.col === col && this.props.isFocused(this.props.Document, true); + // TODO: editing border doesn't work :( + return { + style: { border: !this.props.headerIsEditing && isFocused ? '2px solid rgb(255, 160, 160)' : '1px solid #f1efeb' }, + }; + }; + + @action setCellIsEditing = (isEditing: boolean) => (this._cellIsEditing = isEditing); + + @action + onKeyDown = (e: KeyboardEvent): void => { + if (!this._cellIsEditing && !this.props.headerIsEditing && this.props.isFocused(this.props.Document, true)) { + // && this.props.isSelected(true)) { + const direction = e.key === 'Tab' ? 'tab' : e.which === 39 ? 'right' : e.which === 37 ? 'left' : e.which === 38 ? 'up' : e.which === 40 ? 'down' : ''; + this._focusedCell = this.changeFocusedCellByDirection(direction, this._focusedCell.row, this._focusedCell.col); + + if (direction) { + const pdoc = FieldValue(this.childDocs[this._focusedCell.row]); + pdoc && this.props.setPreviewDoc(pdoc); + e.stopPropagation(); + } + } else if (e.keyCode === 27) { + this.props.setPreviewDoc(undefined); + e.stopPropagation(); // stopPropagation for left/right arrows + } + }; + + changeFocusedCellByDirection = (direction: string, curRow: number, curCol: number) => { + switch (direction) { + case 'tab': + return { row: curRow + 1 === this.childDocs.length ? 0 : curRow + 1, col: curCol + 1 === this.props.columns.length ? 0 : curCol + 1 }; + case 'right': + return { row: curRow, col: curCol + 1 === this.props.columns.length ? curCol : curCol + 1 }; + case 'left': + return { row: curRow, col: curCol === 0 ? curCol : curCol - 1 }; + case 'up': + return { row: curRow === 0 ? curRow : curRow - 1, col: curCol }; + case 'down': + return { row: curRow + 1 === this.childDocs.length ? curRow : curRow + 1, col: curCol }; + } + return this._focusedCell; + }; + + @action + changeFocusedCellByIndex = (row: number, col: number): void => { + if (this._focusedCell.row !== row || this._focusedCell.col !== col) { + this._focusedCell = { row: row, col: col }; + } + this.props.setFocused(this.props.Document); + }; + + @undoBatch + createRow = action(() => { + this.props.addDocument?.(Docs.Create.TextDocument('', { title: '', _width: 100, _height: 30 })); + this._focusedCell = { row: this.childDocs.length, col: this._focusedCell.col }; + }); + + @undoBatch + @action + createColumn = () => { + const newFieldName = (index: number) => `New field${index ? ` (${index})` : ''}`; + for (let index = 0; index < 100; index++) { + if (this.props.columns.findIndex(col => col.heading === newFieldName(index)) === -1) { + this.props.columns.push(new SchemaHeaderField(newFieldName(index), '#f1efeb')); + break; + } + } + }; + + @action + getColumnType = (column: SchemaHeaderField, doc?: Doc, field?: string): ColumnType => { + if (doc && field && column.type === ColumnType.Any) { + const val = doc[CollectionSchemaCell.resolvedFieldKey(field, doc)]; + if (val instanceof ImageField) return ColumnType.Image; + if (val instanceof Doc) return ColumnType.Doc; + if (val instanceof DateField) return ColumnType.Date; + if (val instanceof List) return ColumnType.List; + } + if (column.type && column.type !== 0) { + return column.type; + } + if (columnTypes.get(column.heading)) { + return (column.type = columnTypes.get(column.heading)!); + } + return (column.type = ColumnType.Any); + }; + + @undoBatch + @action + toggleTextwrap = async () => { + const textwrappedRows = Cast(this.props.Document.textwrappedSchemaRows, listSpec('string'), []); + if (textwrappedRows.length) { + this.props.Document.textwrappedSchemaRows = new List<string>([]); + } else { + const docs = DocListCast(this.props.Document[this.props.fieldKey]); + const allRows = docs instanceof Doc ? [docs[Id]] : docs.map(doc => doc[Id]); + this.props.Document.textwrappedSchemaRows = new List<string>(allRows); + } + }; + + @action + toggleTextWrapRow = (doc: Doc): void => { + const textWrapped = this.textWrappedRows; + const index = textWrapped.findIndex(id => doc[Id] === id); + + index > -1 ? textWrapped.splice(index, 1) : textWrapped.push(doc[Id]); + + this.textWrappedRows = textWrapped; + }; + + @computed + get reactTable() { + const children = this.childDocs; + const hasCollectionChild = children.reduce((found, doc) => found || doc.type === DocumentType.COL, false); + const expanded: { [name: string]: any } = {}; + Array.from(this._openCollections.keys()).map(col => (expanded[col.toString()] = true)); + const rerender = [...this.textWrappedRows]; // TODO: get component to rerender on text wrap change without needign to console.log :(((( + + return ( + <ReactTable + style={{ position: 'relative' }} + data={children} + page={0} + pageSize={children.length} + showPagination={false} + columns={this.tableColumns} + getTrProps={this.getTrProps} + getTdProps={this.getTdProps} + sortable={false} + TrComponent={MovableRow} + sorted={this.sorted} + expanded={expanded} + resized={this.resized} + onResizedChange={this.props.onResizedChange} + // if it has a child, render another table with the children + SubComponent={ + !hasCollectionChild + ? undefined + : row => + row.original.type !== DocumentType.COL ? null : ( + <div style={{ paddingLeft: 57 + 'px' }} className="reactTable-sub"> + <SchemaTable {...this.props} Document={row.original} dataDoc={undefined} childDocs={undefined} /> + </div> + ) + } + /> + ); + } + + onContextMenu = (e: React.MouseEvent): void => { + ContextMenu.Instance.addItem({ description: 'Toggle text wrapping', event: this.toggleTextwrap, icon: 'table' }); + }; + + getField = (row: number, col?: number) => { + const docs = this.childDocs; + + row = row % docs.length; + while (row < 0) row += docs.length; + const columns = this.props.columns; + const doc = docs[row]; + if (col === undefined) { + return doc; + } + if (col >= 0 && col < columns.length) { + const column = this.props.columns[col].heading; + return doc[column]; + } + return undefined; + }; + + createTransformer = (row: number, col: number): Transformer => { + const self = this; + const captures: { [name: string]: Field } = {}; + + const transformer: ts.TransformerFactory<ts.SourceFile> = context => { + return root => { + function visit(node: ts.Node) { + node = ts.visitEachChild(node, visit, context); + if (ts.isIdentifier(node)) { + const isntPropAccess = !ts.isPropertyAccessExpression(node.parent) || node.parent.expression === node; + const isntPropAssign = !ts.isPropertyAssignment(node.parent) || node.parent.name !== node; + if (isntPropAccess && isntPropAssign) { + if (node.text === '$r') { + return ts.createNumericLiteral(row.toString()); + } else if (node.text === '$c') { + return ts.createNumericLiteral(col.toString()); + } else if (node.text === '$') { + if (ts.isCallExpression(node.parent)) { + // captures.doc = self.props.Document; + // captures.key = self.props.fieldKey; + } + } + } + } + + return node; + } + return ts.visitNode(root, visit); + }; + }; + + // const getVars = () => { + // return { capturedVariables: captures }; + // }; + + return { transformer /*getVars*/ }; + }; + + setComputed = (script: string, doc: Doc, field: string, row: number, col: number): boolean => { + script = `const $ = (row:number, col?:number) => { + const rval = (doc as any)[key][row + ${row}]; + return col === undefined ? rval : rval[(doc as any)._schemaHeaders[col + ${col}].heading]; + } + return ${script}`; + const compiled = CompileScript(script, { params: { this: Doc.name }, capturedVariables: { doc: this.props.Document, key: this.props.fieldKey }, typecheck: false, transformer: this.createTransformer(row, col) }); + if (compiled.compiled) { + doc[field] = new ComputedField(compiled); + return true; + } + return false; + }; + + @action + showDoc = (doc: Doc | undefined, dataDoc?: Doc, screenX?: number, screenY?: number) => { + this._showDoc = doc; + if (dataDoc && screenX && screenY) { + this._showDocPos = this.props.ScreenToLocalTransform().transformPoint(screenX, screenY); + } + }; + + onOpenClick = () => { + this._showDoc && this.props.addDocTab(this._showDoc, 'add:right'); + }; + + getPreviewTransform = (): Transform => { + return this.props.ScreenToLocalTransform().translate(-this.borderWidth - 4 - this.tableWidth, -this.borderWidth); + }; + + render() { + const preview = ''; + return ( + <div + className="collectionSchemaView-table" + onPointerDown={this.props.onPointerDown} + onClick={this.props.onClick} + onWheel={e => this.props.active(true) && e.stopPropagation()} + onDrop={e => this.props.onDrop(e, {})} + onContextMenu={this.onContextMenu}> + {this.reactTable} + {this.props.Document._chromeHidden || this.props.addDocument === returnFalse ? undefined : ( + <div className="collectionSchemaView-addRow" onClick={this.createRow}> + + new + </div> + )} + {!this._showDoc ? null : ( + <div + className="collectionSchemaView-documentPreview" + ref="overlay" + style={{ + position: 'absolute', + width: 150, + height: 150, + background: 'dimgray', + display: 'block', + top: 0, + left: 0, + transform: `translate(${this._showDocPos[0]}px, ${this._showDocPos[1] - 180}px)`, + }}> + <DocumentView + Document={this._showDoc} + DataDoc={this._showDataDoc} + styleProvider={DefaultStyleProvider} + docViewPath={returnEmptyDoclist} + focus={DocUtils.DefaultFocus} + renderDepth={this.props.renderDepth} + rootSelected={returnFalse} + isContentActive={returnTrue} + isDocumentActive={returnFalse} + PanelWidth={() => 150} + PanelHeight={() => 150} + ScreenToLocalTransform={this.getPreviewTransform} + docFilters={returnEmptyFilter} + docRangeFilters={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + ContainingCollectionDoc={this.props.CollectionView?.props.Document} + ContainingCollectionView={this.props.CollectionView} + moveDocument={this.props.moveDocument} + whenChildContentsActiveChanged={emptyFunction} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + bringToFront={returnFalse}></DocumentView> + </div> + )} + </div> + ); + } +} |