diff options
Diffstat (limited to 'src')
29 files changed, 3083 insertions, 444 deletions
diff --git a/src/ClientUtils.ts b/src/ClientUtils.ts index 630d7edbc..d64210ce2 100644 --- a/src/ClientUtils.ts +++ b/src/ClientUtils.ts @@ -322,6 +322,26 @@ export namespace ClientUtils { return { h: h, s: s, l: l }; } + export function lightenRGB(rVal: number, gVal: number, bVal: number, percent: number): [number, number, number] { + const amount = 1 + percent/100; + const r = rVal * amount; + const g = gVal * amount; + const b = bVal * amount; + + const threshold = 255.999; + const maxVal = Math.max(r, g, b); + if (maxVal <= threshold) { + return [Math.round(r), Math.round(g), Math.round(b)]; + } + const total = r + g + b; + if (total >= 3 * threshold) { + return [Math.round(threshold), Math.round(threshold), Math.round(threshold)]; + } + const x = (3 * threshold - total) / (3 * maxVal - total); + const gray = threshold - x * maxVal; + return [Math.round(gray + x * r), Math.round(gray + x * g), Math.round(gray + x * b)]; + } + export function scrollIntoView(targetY: number, targetHgt: number, scrollTop: number, contextHgt: number, minSpacing: number, scrollHeight: number) { if (!targetHgt) return targetY; // if there's no height, then assume that if (scrollTop + contextHgt < Math.min(scrollHeight, targetY + minSpacing + targetHgt)) { @@ -333,6 +353,8 @@ export namespace ClientUtils { return undefined; } + + export function GetClipboardText(): string { const textArea = document.createElement('textarea'); document.body.appendChild(textArea); diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index b96fdb4bd..751fe6d91 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -43,7 +43,7 @@ export class FInfo { readOnly: boolean = false; fieldType?: FInfoFieldType; values?: FieldType[]; - + onLayout?: boolean; filterable?: boolean = true; // can be used as a Filter in FilterPanel // format?: string; // format to display values (e.g, decimal places, $, etc) // parse?: ScriptField; // parse a value from a string @@ -177,6 +177,7 @@ export class DocumentOptions { map_pitch?: NUMt = new NumInfo('pitch of a map view', false); map_bearing?: NUMt = new NumInfo('bearing of a map view', false); map_style?: STRt = new StrInfo('mapbox style for a map view', false); + identifier?: STRt = new StrInfo('documentIcon displayed for each doc as "d[x]"', false); date_range?: STRt = new StrInfo('date range for calendar', false); @@ -226,12 +227,15 @@ export class DocumentOptions { _header_pointerEvents?: PEVt = new PEInfo('types of events the header of a custom text document can consume'); _lockedPosition?: BOOLt = new BoolInfo("lock the x,y coordinates of the document so that it can't be dragged"); _lockedTransform?: BOOLt = new BoolInfo('lock the freeform_panx,freeform_pany and scale parameters of the document so that it be panned/zoomed'); + _childrenSharedWithSchema?: BOOLt = new BoolInfo("whether this document's children are displayed in its parent schema view", false); + _lockedSchemaEditing?: BOOLt = new BoolInfo("", false); dataViz_title?: string; dataViz_line?: string; dataViz_pie?: string; dataViz_histogram?: string; dataViz?: string; + dataViz_savedTemplates?: LISTt; layout?: string | Doc; // default layout string or template document layout_isSvg?: BOOLt = new BoolInfo('whether document decorations and other selections should handle pointerEvents for svg content or use doc bounding box'); diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index 96b8b5657..ca288303d 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -46,6 +46,7 @@ export class DocumentManager { DocumentView.addViewRenderedCb = this.AddViewRenderedCb; DocumentView.getFirstDocumentView = this.getFirstDocumentView; DocumentView.getDocumentView = this.getDocumentView; + DocumentView.getDocViewIndex = this.getDocViewIndex; DocumentView.getContextPath = DocumentManager.GetContextPath; DocumentView.getLightboxDocumentView = this.getLightboxDocumentView; observe(Doc.CurrentlyLoading, change => { @@ -138,6 +139,16 @@ export class DocumentManager { ); } + public getDocViewIndex(target: Doc): number { + const docViewArray = DocumentManager.Instance.DocumentViews; + for (let i = 0; i < docViewArray.length; ++i){ + if (docViewArray[i].Document == target){ + return i; + } + } + return -1; + } + public getLightboxDocumentView = (toFind: Doc): DocumentView | undefined => { const views: DocumentView[] = []; DocumentManager.Instance.DocumentViews.forEach(view => DocumentView.LightboxContains(view) && Doc.AreProtosEqual(view.Document, toFind) && views.push(view)); diff --git a/src/client/util/Scripting.ts b/src/client/util/Scripting.ts index 6948469cc..5202e62a3 100644 --- a/src/client/util/Scripting.ts +++ b/src/client/util/Scripting.ts @@ -88,7 +88,6 @@ function Run(script: string | undefined, customParams: string[], diagnostics: an if (!options.editable) { batch = Doc.MakeReadOnly(); } - const result = compiledFunction.apply(thisParam, params).apply(thisParam, argsArray); batch?.end(); return { success: true, result }; @@ -184,14 +183,19 @@ function forEachNode(node: ts.Node, onEnter: Traverser, onExit?: Traverser, inde ); } +// ScriptField.CompileScript(value, {}, true, undefined, DocumentIconContainer.getTransformer()); +// //addreturn = true +// //capturedvariables = undefined +// // + export function CompileScript(script: string, options: ScriptOptions = {}): CompileResult { - const captured = options.capturedVariables ?? {}; + const captured = options.capturedVariables ?? {}; const signature = Object.keys(captured).reduce((p, v) => { const formatCapture = (obj: any) => `${v}=${obj instanceof RefField ? 'XXX' : obj.toString()}`; if (captured[v] instanceof Array) return p + (captured[v] as any).map(formatCapture); return p + formatCapture(captured[v]); }, ''); - const found = ScriptField.GetScriptFieldCache(script + ':' + signature); + const found = ScriptField.GetScriptFieldCache(script + ':' + signature); // if already compiled, found is the result; cache set below if (found) return found as CompiledScript; const { requiredType = '', addReturn = false, params = {}, capturedVariables = {}, typecheck = true } = options; if (options.params && !options.params.this) options.params.this = Doc.name; diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts index 0b942116c..fc494583f 100644 --- a/src/client/util/SelectionManager.ts +++ b/src/client/util/SelectionManager.ts @@ -58,6 +58,7 @@ export class SelectionManager { }); public static DeselectAll = (except?: Doc): void => { + const found = this.Instance.SelectedViews.find(dv => dv.Document === except); runInAction(() => { if (LinkManager.Instance) { diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx index d784a14b8..de985263d 100644 --- a/src/client/views/ContextMenu.tsx +++ b/src/client/views/ContextMenu.tsx @@ -269,7 +269,6 @@ export class ContextMenu extends ObservableReactComponent<{}> { // if (this._searchString.startsWith(this._defaultPrefix)) { this._defaultItem?.(this._searchString.substring(this._defaultPrefix.length)); } - this.closeMenu(); e.preventDefault(); e.stopPropagation(); } diff --git a/src/client/views/EditableView.scss b/src/client/views/EditableView.scss index 27b260450..fa4542ac4 100644 --- a/src/client/views/EditableView.scss +++ b/src/client/views/EditableView.scss @@ -3,10 +3,17 @@ overflow-wrap: break-word; word-wrap: break-word; hyphens: auto; - overflow: hidden; + overflow-y: auto; height: 100%; + width: 100%; min-width: 20; text-overflow: ellipsis; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.editableView-container-editing::-webkit-scrollbar { + display: none; } .editableView-container-editing-oneLine { @@ -37,3 +44,4 @@ border: none; outline: none; } + diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx index 684b948af..af6a43555 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -1,6 +1,6 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ -import { action, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import * as Autosuggest from 'react-autosuggest'; @@ -10,6 +10,8 @@ import { DocumentIconContainer } from './nodes/DocumentIcon'; import { FieldView, FieldViewProps } from './nodes/FieldView'; import { ObservableReactComponent } from './ObservableReactComponent'; import { OverlayView } from './OverlayView'; +import { Padding } from 'browndash-components'; +import { SchemaFieldType } from './collections/collectionSchema/SchemaColumnHeader'; export interface EditableProps { /** @@ -54,6 +56,13 @@ export interface EditableProps { background?: string | undefined; placeholder?: string; wrap?: string; // nowrap, pre-wrap, etc + + schemaFieldType?: SchemaFieldType; + prohibitedText?: Array<string>; + onClick?: () => void; + updateAlt?: (newAlt: string) => void; + updateSearch?: (value: string) => void; + highlightCells?: (text: string) => void; } /** @@ -65,18 +74,17 @@ export interface EditableProps { export class EditableView extends ObservableReactComponent<EditableProps> { private _ref = React.createRef<HTMLDivElement>(); private _inputref: HTMLInputElement | HTMLTextAreaElement | null = null; + private _disposers: { [name: string]: IReactionDisposer } = {}; _overlayDisposer?: () => void; - _editingDisposer?: IReactionDisposer; @observable _editing: boolean = false; constructor(props: EditableProps) { super(props); makeObservable(this); - this._editing = !!this._props.editing; } componentDidMount(): void { - this._editingDisposer = reaction( + this._disposers.editing = reaction( () => this._editing, editing => { if (editing) { @@ -84,11 +92,13 @@ export class EditableView extends ObservableReactComponent<EditableProps> { if (this._inputref?.value.startsWith('=') || this._inputref?.value.startsWith(':=')) { this._overlayDisposer?.(); this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); - } + this._props.highlightCells?.(this._props.GetValue() ?? ''); + } }); } else { this._overlayDisposer?.(); this._overlayDisposer = undefined; + this._props.highlightCells?.(''); } }, { fireImmediately: true } @@ -107,7 +117,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { componentWillUnmount() { this._overlayDisposer?.(); - this._editingDisposer?.(); + this._disposers.editing?.(); this._inputref?.value && this.finalizeEdit(this._inputref.value, false, true, false); } @@ -119,6 +129,8 @@ export class EditableView extends ObservableReactComponent<EditableProps> { } else if (!this._overlayDisposer) { this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); } + this._props.updateSearch && this._props.updateSearch(targVal); + this._props.highlightCells?.(targVal); }; @action @@ -155,7 +167,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { case 'ArrowDown': case 'ArrowLeft': case 'ArrowRight': - e.stopPropagation(); + //e.stopPropagation(); break; case 'Shift': case 'Alt': @@ -179,9 +191,10 @@ export class EditableView extends ObservableReactComponent<EditableProps> { }; @action - onClick = (e: React.MouseEvent) => { + onClick = (e?: React.MouseEvent) => { + this._props.onClick && this._props.onClick(); if (this._props.editing !== false) { - e.nativeEvent.stopPropagation(); + e?.nativeEvent.stopPropagation(); if (this._ref.current && this._props.showMenuOnLoad) { this._props.menuCallback?.(this._ref.current.getBoundingClientRect().x, this._ref.current.getBoundingClientRect().y); } else { @@ -190,7 +203,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { } } }; - + @action finalizeEdit(value: string, shiftDown: boolean, lostFocus: boolean, enterKey: boolean) { if (this._props.SetValue(value, shiftDown, enterKey)) { @@ -221,6 +234,12 @@ export class EditableView extends ObservableReactComponent<EditableProps> { return wasFocused !== this._editing; }; + @action + setIsEditing = (value: boolean) => { + this._editing = value; + return this._editing; + } + renderEditor() { return this._props.autosuggestProps ? ( <Autosuggest @@ -240,11 +259,11 @@ export class EditableView extends ObservableReactComponent<EditableProps> { onChange: this._props.autosuggestProps.onChange, }} /> - ) : this._props.oneLine !== false && this._props.GetValue()?.toString().indexOf('\n') === -1 ? ( + ) : ( this._props.oneLine !== false && this._props.GetValue()?.toString().indexOf('\n') === -1 ? ( <input - className="editableView-input" + className="editableView-input" ref={r => { this._inputref = r; }} // prettier-ignore - style={{ display: this._props.display, overflow: 'auto', fontSize: this._props.fontSize, minWidth: 20, background: this._props.background }} + style={{ display: this._props.display, overflow: 'auto', fontSize: this._props.fontSize, minWidth: 20, background: this._props.background}} placeholder={this._props.placeholder} onBlur={e => this.finalizeEdit(e.currentTarget.value, false, true, false)} defaultValue={this._props.GetValue()} @@ -272,16 +291,42 @@ export class EditableView extends ObservableReactComponent<EditableProps> { onClick={this.stopPropagation} onPointerUp={this.stopPropagation} /> - ); + )); + } + + staticDisplay = () => { + let toDisplay; + const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n'); + if (this._props.schemaFieldType === SchemaFieldType.Header){ + toDisplay = <input className="editableView-input" + value={gval} + placeholder='Add key' + readOnly + style={{ display: this._props.display, overflow: 'auto', pointerEvents: 'none', fontSize: this._props.fontSize, width: '100%', margin: 0, background: this._props.background}} + // eslint-disable-next-line jsx-a11y/no-autofocus + /> + } else { + toDisplay = (<span className='editableView-static' + style={{ + fontStyle: this._props.fontStyle, + fontSize: this._props.fontSize + }}> + { + // eslint-disable-next-line react/jsx-props-no-spreading + this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : this.props.contents ? this._props.contents?.valueOf() : '' + } + </span>) + } + + return toDisplay; } render() { const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n'); - if (this._editing && gval !== undefined) { + if ((this._editing && gval !== undefined)) { return this._props.sizeToContent ? ( <div style={{ display: 'grid', minWidth: 100 }}> - <div style={{ display: 'inline-block', position: 'relative', height: 0, width: '100%', overflow: 'hidden' }}>{gval}</div> - {this.renderEditor()} + <div style={{ display: 'inline-block', position: 'relative', height: 0, width: '100%', overflow: 'hidden' }}>{this.renderEditor()}</div> </div> ) : ( this.renderEditor() @@ -298,21 +343,13 @@ export class EditableView extends ObservableReactComponent<EditableProps> { minHeight: '10px', whiteSpace: this._props.oneLine ? 'nowrap' : 'pre-line', height: this._props.height, + width: '100%', maxHeight: this._props.maxHeight, fontStyle: this._props.fontStyle, fontSize: this._props.fontSize, }} onClick={this.onClick}> - <span - style={{ - fontStyle: this._props.fontStyle, - fontSize: this._props.fontSize, - }}> - { - // eslint-disable-next-line react/jsx-props-no-spreading - this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : this.props.contents ? this._props.contents?.valueOf() : '' - } - </span> + {this.staticDisplay()} </div> ); } diff --git a/src/client/views/FieldsDropdown.tsx b/src/client/views/FieldsDropdown.tsx index 0ea0ebd83..011cd51b3 100644 --- a/src/client/views/FieldsDropdown.tsx +++ b/src/client/views/FieldsDropdown.tsx @@ -34,7 +34,7 @@ export class FieldsDropdown extends ObservableReactComponent<fieldsDropdownProps makeObservable(this); } - @computed get allDescendantDocs() { + @computed get allDescendantDocs() { //!!! const allDocs = new Set<Doc>(); SearchUtil.foreachRecursiveDoc([this._props.Document], (depth, doc) => allDocs.add(doc)); return Array.from(allDocs); @@ -57,7 +57,7 @@ export class FieldsDropdown extends ObservableReactComponent<fieldsDropdownProps const filteredOptions = ['author', ...(this._newField ? [this._newField] : []), ...(this._props.addedFields ?? []), ...this.fieldsOfDocuments.filter(facet => facet[0] === facet.charAt(0).toUpperCase())]; Object.entries(DocOptions) - .filter(opts => opts[1].filterable) + .filter(opts => opts[1].filterable) //!!! .forEach((pair: [string, FInfo]) => filteredOptions.push(pair[0])); const options = filteredOptions.sort().map(facet => ({ value: facet, label: facet })); diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index ef1bcfb64..dd5884cf2 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -76,6 +76,7 @@ import { PresBox } from './nodes/trails'; import { AnchorMenu } from './pdf/AnchorMenu'; import { GPTPopup } from './pdf/GPTPopup/GPTPopup'; import { TopBar } from './topbar/TopBar'; +import { DocCreatorMenu } from './nodes/DataVizBox/DocCreatorMenu'; const { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } = require('./global/globalCssVariables.module.scss'); // prettier-ignore const _global = (window /* browser */ || global) /* node */ as any; @@ -278,6 +279,16 @@ export class MainView extends ObservableReactComponent<{}> { library.add( ...[ + fa.faFloppyDisk, + fa.faRepeat, + fa.faArrowsUpDown, + fa.faArrowsLeftRight, + fa.faWindowMaximize, + fa.faGift, + fa.faLockOpen, + fa.faSort, + fa.faArrowUpZA, + fa.faArrowDownAZ, fa.faExclamationCircle, fa.faEdit, fa.faArrowDownShortWide, @@ -1088,6 +1099,7 @@ export class MainView extends ObservableReactComponent<{}> { <PreviewCursor /> <TaskCompletionBox /> <ContextMenu /> + <DocCreatorMenu/> <ImageLabelHandler /> <AnchorMenu /> <MapAnchorMenu /> diff --git a/src/client/views/ScriptingRepl.scss b/src/client/views/ScriptingRepl.scss index adc82238e..5fe176920 100644 --- a/src/client/views/ScriptingRepl.scss +++ b/src/client/views/ScriptingRepl.scss @@ -35,6 +35,8 @@ opacity: 0.3; } + + .scriptingObject-icon { padding: 3px; cursor: pointer; diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.scss b/src/client/views/collections/collectionSchema/CollectionSchemaView.scss index 6fb8e40db..c32661214 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.scss +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.scss @@ -50,18 +50,15 @@ .schema-column-menu, .schema-filter-menu { background: $light-gray; - position: relative; - min-width: 200px; - max-width: 400px; + position: absolute; + border: 1px solid $medium-gray; + border-bottom: 2px solid $medium-gray; + max-height: 201px; display: flex; + overflow: hidden; flex-direction: column; align-items: flex-start; - z-index: 1; - - .schema-key-search-input { - width: calc(100% - 20px); - margin: 10px; - } + z-index: 5; .schema-search-result { cursor: pointer; @@ -104,7 +101,7 @@ .schema-key-list { width: 100%; - max-height: 300px; + max-height: 250px; overflow-y: auto; } @@ -153,12 +150,18 @@ padding: 0; z-index: 1; border: 1px solid $medium-gray; - //overflow: hidden; .schema-column-title { flex-grow: 2; margin: 5px; overflow: hidden; + min-width: 100%; + } + + .schema-column-edit-wrapper { + flex-grow: 2; + margin: 5px; + overflow: hidden; min-width: 20%; } @@ -176,6 +179,11 @@ } } + .editableView-input { + border: none; + outline: none; + } + /*.schema-column-resizer.left { min-width: 5px; transform: translate(-3px, 0px); @@ -245,9 +253,6 @@ flex-direction: row; min-width: 50px; justify-content: center; - .iconButton-container { - min-width: unset !important; - } } .row-cells { @@ -255,6 +260,20 @@ flex-direction: row; justify-content: flex-end; } + + .row-menu-infos { + position: absolute; + top: 3; + left: 3; + z-index: 1; + display: flex; + justify-content: flex-end; + align-items: center; + + .row-infos-icon { + padding-right: 2px; + } + } } .schema-row-button, @@ -287,3 +306,9 @@ width: 12px; } } + +.schemaField-editing { + outline: none; +} + + diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx index 6bea53355..12c342b9f 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx @@ -1,17 +1,17 @@ /* eslint-disable no-restricted-syntax */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Popup, PopupTrigger, Type } from 'browndash-components'; -import { ObservableMap, action, computed, makeObservable, observable, observe, runInAction } from 'mobx'; +import { IconButton, Popup, PopupTrigger, Size, Type } from 'browndash-components'; +import { IReactionDisposer, ObservableMap, action, autorun, computed, makeObservable, observable, observe, override, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { returnEmptyDoclist, returnEmptyString, returnFalse, returnIgnore, returnNever, returnTrue, setupMoveUpEvents, smoothScroll } from '../../../../ClientUtils'; +import { ClientUtils, returnEmptyDoclist, returnEmptyString, returnFalse, returnIgnore, returnNever, returnTrue, setupMoveUpEvents, smoothScroll } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; -import { Doc, DocListCast, Field, FieldType, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; -import { DocData } from '../../../../fields/DocSymbols'; +import { Doc, DocListCast, Field, FieldType, IdToDoc, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; +import { AclPrivate, DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; import { ColumnType } from '../../../../fields/SchemaHeaderField'; -import { BoolCast, NumCast, StrCast } from '../../../../fields/Types'; +import { BoolCast, Cast, NumCast, StrCast } from '../../../../fields/Types'; import { DocUtils } from '../../../documents/DocUtils'; import { Docs, DocumentOptions, FInfo } from '../../../documents/Documents'; import { DragManager } from '../../../util/DragManager'; @@ -31,6 +31,20 @@ import { CollectionSubView } from '../CollectionSubView'; import './CollectionSchemaView.scss'; import { SchemaColumnHeader } from './SchemaColumnHeader'; import { SchemaRowBox } from './SchemaRowBox'; +import { ActionButton } from '@adobe/react-spectrum'; +import { CollectionMasonryViewFieldRow } from '../CollectionMasonryViewFieldRow'; +import { Func } from 'mocha'; +import { CollectionView } from '../CollectionView'; +import { listSpec } from '../../../../fields/Schema'; +import { GetEffectiveAcl } from '../../../../fields/util'; +import { ContextMenuProps } from '../../ContextMenuItem'; +import { truncate } from 'lodash'; +import { DocumentManager } from '../../../util/DocumentManager'; +import { TbHemispherePlus } from 'react-icons/tb'; +import { docs_v1 } from 'googleapis'; +import { SchemaCellField } from './SchemaCellField'; +import { threadId } from 'worker_threads'; +import { FontIconBox } from '../../nodes/FontIconBox/FontIconBox'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { SCHEMA_NEW_NODE_HEIGHT } = require('../../global/globalCssVariables.module.scss'); // prettier-ignore @@ -50,15 +64,30 @@ const defaultColumnKeys: string[] = ['title', 'type', 'author', 'author_date', ' @observer export class CollectionSchemaView extends CollectionSubView() { private _keysDisposer: any; + private _disposers: { [name: string]: IReactionDisposer } = {}; private _previewRef: HTMLDivElement | null = null; private _makeNewColumn: boolean = false; private _documentOptions: DocumentOptions = new DocumentOptions(); private _tableContentRef: HTMLDivElement | null = null; private _menuTarget = React.createRef<HTMLDivElement>(); + private _headerRefs: SchemaColumnHeader[] = []; + private _eqHighlightColors: Array<[{r: number, g: number, b: number}, {r: number, g: number, b: number}]> = []; constructor(props: any) { super(props); makeObservable(this); + const lightenedColor = (r: number, g: number, b:number) => { const lightened = ClientUtils.lightenRGB(r, g, b, 165); return {r: lightened[0], g: lightened[1], b: lightened[2]}} // prettier-ignore + const colors = (r: number, g: number, b: number): [any, any] => {return [{r: r, g: g, b: b}, lightenedColor(r, g, b)]} // prettier-ignore + this._eqHighlightColors.push(colors(70, 150, 50)); + this._eqHighlightColors.push(colors(180, 70, 20)); + this._eqHighlightColors.push(colors(70, 50, 150)); + this._eqHighlightColors.push(colors(0, 140, 140)); + this._eqHighlightColors.push(colors(140, 30, 110)); + this._eqHighlightColors.push(colors(20, 50, 200)); + this._eqHighlightColors.push(colors(210, 30, 40)); + this._eqHighlightColors.push(colors(120, 130, 30)); + this._eqHighlightColors.push(colors(50, 150, 70)); + this._eqHighlightColors.push(colors(10, 90, 180)); } static _rowHeight: number = 50; @@ -83,12 +112,18 @@ export class CollectionSchemaView extends CollectionSubView() { @observable _filterSearchValue: string = ''; @observable _selectedCol: number = 0; @observable _selectedCells: Array<Doc> = []; - @observable _mouseCoordinates = { x: 0, y: 0 }; - @observable _lowestSelectedIndex = -1; // lowest index among selected rows; used to properly sync dragged docs with cursor position - @observable _relCursorIndex = -1; // cursor index relative to the current selected cells - @observable _draggedColIndex = 0; - @observable _colBeingDragged = false; - + @observable _mouseCoordinates = { x: 0, y: 0, prevX: 0, prevY: 0 }; + @observable _lowestSelectedIndex: number = -1; //lowest index among selected rows; used to properly sync dragged docs with cursor position + @observable _relCursorIndex: number = -1; //cursor index relative to the current selected cells + @observable _draggedColIndex: number = 0; + @observable _colBeingDragged: boolean = false; + @observable _colKeysFiltered: boolean = false; + @observable _cellTags: ObservableMap = new ObservableMap<Doc, Array<string>>(); + @observable _highlightedCellsInfo: Array<[doc: Doc, field: string]> = []; + @observable _cellHighlightColors: ObservableMap = new ObservableMap<string, string[]>(); + @observable _docs: Doc[] = []; + @observable _referenceSelectMode: {enabled: boolean, currEditing: SchemaCellField | undefined} = {enabled: false, currEditing: undefined} + // target HTMLelement portal for showing a popup menu to edit cell values. public get MenuTarget() { return this._menuTarget.current; @@ -96,7 +131,8 @@ export class CollectionSchemaView extends CollectionSubView() { @computed get _selectedDocs() { // get all selected documents then filter out any whose parent is not this schema document - const selected = DocumentView.SelectedDocs().filter(doc => this.childDocs.includes(doc)); + const selected = DocumentView.SelectedDocs().filter(doc => this.docs.includes(doc)); + //&& this._selectedCells.includes(doc) if (!selected.length) { // if no schema doc is directly selected, test if a child of a schema doc is selected (such as in the preview window) const childOfSchemaDoc = DocumentView.SelectedDocs().find(sel => DocumentView.getContextPath(sel, true).includes(this.Document)); @@ -108,6 +144,10 @@ export class CollectionSchemaView extends CollectionSubView() { return selected; } + @computed get highlightedCells() { + return this._highlightedCellsInfo.map(info => this.getCellElement(info[0], info[1])); + } + @computed get documentKeys() { return Array.from(this.fieldInfos.keys()); } @@ -131,7 +171,6 @@ export class CollectionSchemaView extends CollectionSubView() { ); const totalWidth = widths.reduce((sum, width) => sum + width, 0); - // If the total width of all columns is not the width of the schema table minus the width of the row menu, resize them appropriately if (totalWidth !== this.tableWidth - CollectionSchemaView._rowMenuWidth) { return widths.map(w => (w / totalWidth) * (this.tableWidth - CollectionSchemaView._rowMenuWidth)); } @@ -139,7 +178,7 @@ export class CollectionSchemaView extends CollectionSubView() { } @computed get rowHeights() { - return this.childDocs.map(() => this.rowHeightFunc()); + return this.docs.map(() => this.rowHeightFunc()); } @computed get displayColumnWidths() { @@ -177,17 +216,33 @@ export class CollectionSchemaView extends CollectionSubView() { }, true ); + this._disposers.docdata = reaction( + () => DocListCast(this.dataDoc[this.fieldKey]), + (docs) => this._docs = docs, + {fireImmediately: true} + ) + this._disposers.sortHighlight = reaction( + () => [this.sortField, this._docs, this._selectedDocs, this._highlightedCellsInfo], + () => {this.sortField && setTimeout(() => this.highlightSortedColumn())}, + {fireImmediately: true} + ) } componentWillUnmount() { this._keysDisposer?.(); + Object.values(this._disposers).forEach(disposer => disposer?.()); document.removeEventListener('keydown', this.onKeyDown); } // ViewBoxInterface overrides override isUnstyledView = returnTrue; // used by style provider : turns off opacity, animation effects, scaling - rowIndex = (doc: Doc) => this.sortedDocs.docs.indexOf(doc); + removeDoc = (doc: Doc) => { + this.removeDocument(doc); + this._docs = this._docs.filter(d => d !== doc) + } + + rowIndex = (doc: Doc) => this.docsWithDrag.docs.indexOf(doc); @action onKeyDown = (e: KeyboardEvent) => { @@ -197,9 +252,9 @@ export class CollectionSchemaView extends CollectionSubView() { { const lastDoc = this._selectedDocs.lastElement(); const lastIndex = this.rowIndex(lastDoc); - const curDoc = this.sortedDocs.docs[lastIndex]; + const curDoc = this.docs[lastIndex]; if (lastIndex >= 0 && lastIndex < this.childDocs.length - 1) { - const newDoc = this.sortedDocs.docs[lastIndex + 1]; + const newDoc = this.docs[lastIndex + 1]; if (this._selectedDocs.includes(newDoc)) { DocumentView.DeselectView(DocumentView.getFirstDocumentView(curDoc)); this.deselectCell(curDoc); @@ -216,9 +271,9 @@ export class CollectionSchemaView extends CollectionSubView() { { const firstDoc = this._selectedDocs.lastElement(); const firstIndex = this.rowIndex(firstDoc); - const curDoc = this.sortedDocs.docs[firstIndex]; + const curDoc = this.docs[firstIndex]; if (firstIndex > 0 && firstIndex < this.childDocs.length) { - const newDoc = this.sortedDocs.docs[firstIndex - 1]; + const newDoc = this.docs[firstIndex - 1]; if (this._selectedDocs.includes(newDoc)) { DocumentView.DeselectView(DocumentView.getFirstDocumentView(curDoc)); this.deselectCell(curDoc); @@ -246,13 +301,20 @@ export class CollectionSchemaView extends CollectionSubView() { } break; case 'Backspace': { - undoable(() => this.removeDocument(this._selectedDocs), 'delete schema row'); + // this._docs.forEach(doc => { + // if (!this.childDocs.concat(this.displayedSubCollectionDocs(this.Document))) + // }); + // console.log('backspace detected') + undoable(() => {this._selectedDocs.forEach(d => this._docs.includes(d) && this.removeDoc(d));}, 'delete schema row'); break; } case 'Escape': { this.deselectAllCells(); break; } + case 'P': { + break; + } default: } } @@ -261,19 +323,11 @@ export class CollectionSchemaView extends CollectionSubView() { @action changeSelectedCellColumn = () => {}; - @undoBatch - setColumnSort = (field: string | undefined, desc: boolean = false) => { - this.layoutDoc.sortField = field; - this.layoutDoc.sortDesc = desc; - }; - addRow = (doc: Doc | Doc[]) => this.addDocument(doc); @undoBatch changeColumnKey = (index: number, newKey: string, defaultVal?: any) => { - if (!this.documentKeys.includes(newKey)) { - this.addNewKey(newKey, defaultVal); - } + if (!this.documentKeys.includes(newKey)) this.addNewKey(newKey, defaultVal); const currKeys = this.columnKeys.slice(); // copy the column key array first, then change it. currKeys[index] = newKey; @@ -281,31 +335,36 @@ export class CollectionSchemaView extends CollectionSubView() { }; @undoBatch - addColumn = (key: string, defaultVal?: any) => { - if (!this.documentKeys.includes(key)) { - this.addNewKey(key, defaultVal); - } - + addColumn = (index: number = 0, key?: string, defaultVal?: any) => { + if (key && !this.documentKeys.includes(key)) this.addNewKey(key, defaultVal); + const newColWidth = this.tableWidth / (this.storedColumnWidths.length + 1); const currWidths = this.storedColumnWidths.slice(); - currWidths.splice(0, 0, newColWidth); + currWidths.splice(index, 0, newColWidth); const newDesiredTableWidth = currWidths.reduce((w, cw) => w + cw, 0); this.layoutDoc.schema_columnWidths = new List<number>(currWidths.map(w => (w / newDesiredTableWidth) * (this.tableWidth - CollectionSchemaView._rowMenuWidth))); const currKeys = this.columnKeys.slice(); - currKeys.splice(0, 0, key); + if (!key) key = 'EmptyColumnKey' + Math.floor(Math.random() * 1000000000000000).toString(); + currKeys.splice(index, 0, key); + this.changeColumnKey(index, 'EmptyColumnKey' + Math.floor(Math.random() * 1000000000000000).toString()); this.layoutDoc.schema_columnKeys = new List<string>(currKeys); }; @action - addNewKey = (key: string, defaultVal: any) => + addNewKey = (key: string, defaultVal: any) => { this.childDocs.forEach(doc => { doc[DocData][key] = defaultVal; }); + } @undoBatch removeColumn = (index: number) => { if (this.columnKeys.length === 1) return; + if (this._columnMenuIndex === index) { + this._headerRefs[index].toggleEditing(false); + this.closeColumnMenu(); + } const currWidths = this.storedColumnWidths.slice(); currWidths.splice(index, 1); const newDesiredTableWidth = currWidths.reduce((w, cw) => w + cw, 0); @@ -313,24 +372,29 @@ export class CollectionSchemaView extends CollectionSubView() { const currKeys = this.columnKeys.slice(); currKeys.splice(index, 1); - this.layoutDoc.schema_columnKeys = new List<string>(currKeys); + this.layoutDoc.schema_columnKeys = new List<string>(currKeys); + + this._colEles.splice(index, 1); }; @action - startResize = (e: any, index: number) => { + startResize = (e: any, index: number, rightSide: boolean) => { this._displayColumnWidths = this.storedColumnWidths; - setupMoveUpEvents(this, e, moveEv => this.resizeColumn(moveEv, index), this.finishResize, emptyFunction); + setupMoveUpEvents(this, e, moveEv => this.resizeColumn(moveEv, index, rightSide), this.finishResize, emptyFunction); }; @action - resizeColumn = (e: PointerEvent, index: number) => { + resizeColumn = (e: PointerEvent, index: number, rightSide: boolean) => { if (this._displayColumnWidths) { let shrinking; let growing; let change = e.movementX; - if (index !== 0) { + if (rightSide && (index !== this._displayColumnWidths.length - 1)) { + growing = change < 0 ? index + 1: index; + shrinking = change < 0 ? index : index + 1; + } else if (index !== 0) { growing = change < 0 ? index : index - 1; shrinking = change < 0 ? index - 1 : index; } @@ -368,14 +432,14 @@ export class CollectionSchemaView extends CollectionSubView() { const currWidths = this.storedColumnWidths.slice(); currWidths.splice(toIndex, 0, currWidths.splice(fromIndex, 1)[0]); this.layoutDoc.schema_columnWidths = new List<number>(currWidths); - - this._draggedColIndex = toIndex; }; @action dragColumn = (e: PointerEvent, index: number) => { + this.closeColumnMenu(); + this._headerRefs.forEach(ref => ref.toggleEditing(false)); this._draggedColIndex = index; - this._colBeingDragged = true; + this.setColDrag(true); const dragData = new DragManager.ColumnDragData(index); const dragEles = [this._colEles[index]]; this.childDocs.forEach(doc => dragEles.push(this._rowEles.get(doc).children[1].children[index])); @@ -384,6 +448,7 @@ export class CollectionSchemaView extends CollectionSubView() { }; findColDropIndex = (mouseX: number) => { + let xOffset: number = this._props.ScreenToLocalTransform().inverse().transformPoint(0,0)[0] + CollectionSchemaView._rowMenuWidth; let index: number | undefined; this.displayColumnWidths.reduce((total, curr, i) => { if (total <= mouseX && total + curr >= mouseX) { @@ -391,7 +456,7 @@ export class CollectionSchemaView extends CollectionSubView() { else index = i + 1; } return total + curr; - }, 2 * CollectionSchemaView._rowMenuWidth); // probably prone to issues; find better implementation (!!!) + }, xOffset); return index; }; @@ -444,19 +509,164 @@ export class CollectionSchemaView extends CollectionSubView() { highlightDraggedColumn = (index: number) => this._colEles.forEach((colRef, i) => { const edgeStyle = i === index ? `solid 2px ${Colors.MEDIUM_BLUE}` : ''; + const sorted = i === this.columnKeys.indexOf(this.sortField); const cellEles = [ colRef, - ...this.childDocs // - .filter(doc => i !== this._selectedCol || !this._selectedDocs.includes(doc)) + ...this.docsWithDrag.docs + .filter(doc => (i !== this._selectedCol || !this._selectedDocs.includes(doc)) && !sorted) .map(doc => this._rowEles.get(doc).children[1].children[i]), ]; - cellEles[0].style.borderTop = edgeStyle; cellEles.forEach(ele => { + if (sorted || this.highlightedCells.includes(ele)) return; + ele.style.borderTop = ele === cellEles[0] ? edgeStyle : ''; ele.style.borderLeft = edgeStyle; ele.style.borderRight = edgeStyle; + ele.style.borderBottom = ele === cellEles.slice(-1)[0] ? edgeStyle : ''; + }); + }); + + removeDragHighlight = () => { + this._colEles.forEach((colRef, i) => { + const sorted = i === this.columnKeys.indexOf(this.sortField); + if (sorted) return; + + colRef.style.borderLeft = ''; + colRef.style.borderRight = ''; + colRef.style.borderTop = ''; + + this.childDocs.forEach(doc => { + const cell = this._rowEles.get(doc).children[1].children[i]; + if (!(this._selectedDocs.includes(doc) && i === this._selectedCol) && !(this.highlightedCells.includes(cell)) && cell) { + cell.style.borderLeft = ''; + cell.style.borderRight = ''; + cell.style.borderBottom = ''; + } }); - cellEles.slice(-1)[0].style.borderBottom = edgeStyle; }); + } + + highlightSortedColumn = (field?: string, descending?: boolean) => { + let index = -1; + let highlightColors: string[] = []; + const rowCount: number = this._docs.length + 1; + if (field || this.sortField){ + index = this.columnKeys.indexOf(field || this.sortField); + const increment: number = 110/rowCount; + for (let i = 1; i <= rowCount; ++i){ + const adjColor = ClientUtils.lightenRGB(16, 66, 230, increment * i); + highlightColors.push(`solid 2px rgb(${adjColor[0]}, ${adjColor[1]}, ${adjColor[2]})`); + } + } + + this._colEles.forEach((colRef, i) => { + const highlight: boolean = i === index; + const desc: boolean = descending || this.sortDesc; + const cellEles = [ + colRef, + ...this.docsWithDrag.docs + .filter(doc => (i !== this._selectedCol || !this._selectedDocs.includes(doc))) + .map(doc => this._rowEles.get(doc).children[1].children[i]), + ]; + const cellCount = cellEles.length; + for (let ele = 0; ele < cellCount; ++ele){ + const currCell = cellEles[ele]; + if (this.highlightedCells.includes(currCell)) continue; + const style = highlight ? desc ? `${highlightColors[cellCount - 1 - ele]}` : `${highlightColors[ele]}` : ''; + currCell.style.borderLeft = style; + currCell.style.borderRight = style; + } + cellEles[0].style.borderTop = highlight ? desc ? `${highlightColors[cellCount - 1]}` : `${highlightColors[0]}` : ''; + if (!(this._selectedDocs.includes(this.docsWithDrag.docs[this.docsWithDrag.docs.length - 1]) && this._selectedCol === index) && !this.highlightedCells.includes(cellEles[cellCount - 1])) cellEles[cellCount - 1].style.borderBottom = highlight ? desc ? `${highlightColors[0]}` : `${highlightColors[cellCount - 1]}` : ''; + }); + + } + + getCellElement = (doc: Doc, fieldKey: string) => { + const index = this.columnKeys.indexOf(fieldKey); + const cell = this._rowEles.get(doc).children[1].children[index]; + return cell; + } + + findCellRefs = (text: string) => { + const pattern = /(this|d(\d+))\.(\w+)/g; + interface Match { docRef: string; field: string; } + + const matches: Match[] = []; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) !== null) { + const docRef = match[1] === 'this' ? match[1] : match[2]; + matches.push({ docRef, field: match[3] }); + } + + const cells: Array<any> = []; + matches.forEach((match: Match) => { + const {docRef, field} = match; + const docView = DocumentManager.Instance.DocumentViews[Number(docRef)]; + const doc = docView?.Document ?? undefined; + if (this.columnKeys.includes(field) && this._docs.includes(doc)) {cells.push([doc, field])} + }) + + return cells; + } + + selectionOverlap = (doc: Doc): [boolean, boolean] => { + const docs = this.docsWithDrag.docs; + const index = this.rowIndex(doc); + const selectedBelow: boolean = this._selectedDocs.includes(docs[index + 1]); + const selectedAbove: boolean = this._selectedDocs.includes(docs[index - 1]); + return [selectedAbove, selectedBelow]; + } + + @action + removeCellHighlights = () => { + this._highlightedCellsInfo.forEach(info => { + const doc = info[0]; + const field = info[1]; + const cell = this.getCellElement(doc, field); + if (this._selectedDocs.includes(doc) && this._selectedCol === this.columnKeys.indexOf(field)) { + cell.style.border = `solid 2px ${Colors.MEDIUM_BLUE}`; + if (this.selectionOverlap(doc)[0]) cell.style.borderTop = ''; + if (this.selectionOverlap(doc)[1]) cell.style.borderBottom = ''; + } else cell.style.border = ''; + cell.style.backgroundColor = '';}); + this._highlightedCellsInfo = []; + } + + restoreCellHighlights = () => { + this._highlightedCellsInfo.forEach(info => { + const doc = info[0]; + const field = info[1]; + const key = `${doc[Id]}_${field}`; + const cell = this.getCellElement(doc, field); + const color = this._cellHighlightColors.get(key)[0]; + cell.style.borderTop = color; + cell.style.borderLeft = color; + cell.style.borderRight = color; + cell.style.borderBottom = color; + }); + } + + highlightCells = (text: string) => { + this.removeCellHighlights(); + + const cellsToHighlight = this.findCellRefs(text); + this._highlightedCellsInfo = [...cellsToHighlight]; + + for (let i = 0; i < this._highlightedCellsInfo.length; ++i) { + const info = this._highlightedCellsInfo[i]; + const color = this._eqHighlightColors[i % 10]; + const colorStrings = [`solid 2px rgb(${color[0].r}, ${color[0].g}, ${color[0].b})`, `rgb(${color[1].r}, ${color[1].g}, ${color[1].b})`]; + const doc = info[0]; + const field = info[1]; + const key = `${doc[Id]}_${field}`; + console.log(key + ' ' + i % 10 + ' color: ' + color[0].r + color[0].g + color[0].b); + const cell = this.getCellElement(doc, field); + this._cellHighlightColors.set(key, [colorStrings[0], colorStrings[1]]); + cell.style.border = colorStrings[0]; + cell.style.backgroundColor = colorStrings[1]; + } + } @action addRowRef = (doc: Doc, ref: HTMLDivElement) => this._rowEles.set(doc, ref); @@ -478,6 +688,7 @@ export class CollectionSchemaView extends CollectionSubView() { @action clearSelection = () => { + if (this._referenceSelectMode.enabled) return; DocumentView.DeselectAll(); this.deselectAllCells(); }; @@ -488,18 +699,30 @@ export class CollectionSchemaView extends CollectionSubView() { const startRow = Math.min(lastSelectedRow, index); const endRow = Math.max(lastSelectedRow, index); for (let i = startRow; i <= endRow; i++) { - const currDoc = this.sortedDocs.docs[i]; + const currDoc = this.docsWithDrag.docs[i]; if (!this._selectedDocs.includes(currDoc)) { this.selectCell(currDoc, this._selectedCol, false, true); } } }; + selectReference = (doc: Doc | undefined, col: number) => { + if (!doc) return; + const docIndex = DocumentView.getDocViewIndex(doc); + const field = this.columnKeys[col]; + const refToAdd = `d${docIndex}.${field}` + const editedField = this._referenceSelectMode.currEditing ? this._referenceSelectMode.currEditing as SchemaCellField : null; + editedField?.appendText(refToAdd, true); + editedField?.setupRefSelect(false); + return; + } + @action selectCell = (doc: Doc, col: number, shiftKey: boolean, ctrlKey: boolean) => { + this.closeColumnMenu(); if (!shiftKey && !ctrlKey) this.clearSelection(); !this._selectedCells && (this._selectedCells = []); - !shiftKey && this._selectedCells && this._selectedCells.push(doc); + !shiftKey && this._selectedCells.push(doc); const index = this.rowIndex(doc); if (!this) return; @@ -514,8 +737,6 @@ export class CollectionSchemaView extends CollectionSubView() { this._selectedCol = col; if (this._lowestSelectedIndex === -1 || index < this._lowestSelectedIndex) this._lowestSelectedIndex = index; - - // let selectedIndexes: Array<Number> = this._selectedCells.map(doc => this.rowIndex(doc)); }; @action @@ -530,41 +751,24 @@ export class CollectionSchemaView extends CollectionSubView() { this._lowestSelectedIndex = -1; }; - sortedSelectedDocs = () => this.sortedDocs.docs.filter(doc => this._selectedDocs.includes(doc)); - @computed get rowDropIndex() { const mouseY = this.ScreenToLocalBoxXf().transformPoint(this._mouseCoordinates.x, this._mouseCoordinates.y)[1]; return this.findRowDropIndex(mouseY); } + @action onInternalDrop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.columnDragData) { - this._colBeingDragged = false; + setTimeout(() => {this.setColDrag(false);}); e.stopPropagation(); - - this._colEles.forEach((colRef, i) => { - // style for menu cell - colRef.style.borderLeft = ''; - colRef.style.borderRight = ''; - colRef.style.borderTop = ''; - - this.childDocs.forEach(doc => { - if (!(this._selectedDocs.includes(doc) && i === this._selectedCol)) { - this._rowEles.get(doc).children[1].children[i].style.borderLeft = ''; - this._rowEles.get(doc).children[1].children[i].style.borderRight = ''; - this._rowEles.get(doc).children[1].children[i].style.borderBottom = ''; - } - }); - }); return true; } const draggedDocs = de.complete.docDragData?.draggedDocuments; if (draggedDocs && super.onInternalDrop(e, de) && !this.sortField) { - const map = draggedDocs?.map(doc => this.rowIndex(doc)); - console.log(map); - this.dataDoc[this.fieldKey ?? 'data'] = new List<Doc>([...this.sortedDocs.docs]); + const docs = this.docsWithDrag.docs.slice(); + this.dataDoc[this.fieldKey ?? 'data'] = new List<Doc>([...docs]); this.clearSelection(); draggedDocs.forEach(doc => { DocumentView.addViewRenderedCb(doc, dv => dv.select(true)); @@ -683,50 +887,49 @@ export class CollectionSchemaView extends CollectionSubView() { }; @action - setKey = (key: string, defaultVal?: any) => { + setKey = (key: string, defaultVal?: any, index?: number) => { + if (this.columnKeys.includes(key)) return; + if (this._makeNewColumn) { - this.addColumn(key, defaultVal); - } else { - this.changeColumnKey(this._columnMenuIndex!, key, defaultVal); - } + this.addColumn(this.columnKeys.indexOf(key), key, defaultVal); + this._makeNewColumn = false; + } else this.changeColumnKey(this._columnMenuIndex! | index!, key, defaultVal); + this.closeColumnMenu(); }; - setColumnValues = (key: string, value: string) => { - const selectedDocs: Doc[] = []; - this.childDocs.forEach(doc => { - const docIsSelected = this._selectedCells && !(this._selectedCells?.filter(d => d === doc).length === 0); - if (docIsSelected) { - selectedDocs.push(doc); - } - }); - if (selectedDocs.length === 1) { - this.childDocs.forEach(doc => Doc.SetField(doc, key, value)); - } else { - selectedDocs.forEach(doc => Doc.SetField(doc, key, value)); - } + setCellValues = (key: string, value: string) => { + if (this._selectedCells.length === 1) this.docs.forEach(doc => !doc._lockedSchemaEditing && Doc.SetField(doc, key, value)); + else this._selectedCells.forEach(doc => !doc._lockedSchemaEditing && Doc.SetField(doc, key, value)); return true; }; - setSelectedColumnValues = (key: string, value: string) => { - this.childDocs.forEach(doc => { - const docIsSelected = this._selectedCells && !(this._selectedCells?.filter(d => d === doc).length === 0); - if (docIsSelected) { - Doc.SetField(doc, key, value); - } - }); - return true; - }; + @action + toggleMenuKeyFilter = () => { + if (!this._colKeysFiltered){ + this._colKeysFiltered = true; + this._menuKeys = this.documentKeys.filter(key => this.childDocsInclude(key)); + } else { + this._colKeysFiltered = false; + this._menuKeys = this.documentKeys; + } + } + + childDocsInclude = (key: string) => { + let keyExists: boolean = false; + this.childDocs.forEach(doc => {if (Object.keys(doc).includes(key)) keyExists = true;}) + return keyExists + } @action openColumnMenu = (index: number, newCol: boolean) => { + this.closeFilterMenu(); + this._makeNewColumn = false; this._columnMenuIndex = index; this._menuValue = ''; this._menuKeys = this.documentKeys; - this._makeNewField = false; this._newFieldWarning = ''; - this._makeNewField = false; this._makeNewColumn = newCol; }; @@ -746,32 +949,109 @@ export class CollectionSchemaView extends CollectionSubView() { this._filterColumnIndex = undefined; }; + @undoBatch + setColumnSort = (field: string | undefined, desc: boolean = false) => { + this.layoutDoc.sortField = field; + this.layoutDoc.sortDesc = desc; + }; + openContextMenu = (x: number, y: number, index: number) => { this.closeColumnMenu(); this.closeFilterMenu(); - ContextMenu.Instance.clearItems(); - ContextMenu.Instance.addItem({ - description: 'Change field', + const cm = ContextMenu.Instance; + cm.clearItems(); + + const fieldSortedAsc = (this.sortField === this.columnKeys[index] && !this.sortDesc); + const fieldSortedDesc = (this.sortField === this.columnKeys[index] && this.sortDesc); + const revealOptions = cm.findByDescription('Sort column') + const sortOptions: ContextMenuProps[] = revealOptions && revealOptions && 'subitems' in revealOptions ? revealOptions.subitems : []; + sortOptions.push({ + description: 'Sort A-Z', + event: () => { + this.setColumnSort(undefined); + const field = this.columnKeys[index]; + this._docs = this.sortDocs(field, false); + setTimeout(() => { + this.highlightSortedColumn(field, false); + setTimeout(() => this.highlightSortedColumn(), 480); + }, 20); + }, + icon: 'arrow-down-a-z',}); + sortOptions.push({ + description: 'Sort Z-A', + event: () => { + this.setColumnSort(undefined); + const field = this.columnKeys[index]; + this._docs = this.sortDocs(field, true); + setTimeout(() => { + this.highlightSortedColumn(field, true); + setTimeout(() => this.highlightSortedColumn(), 480); + }, 20); + }, + icon: 'arrow-up-z-a'}); + sortOptions.push({ + description: 'Persistent Sort A-Z', + event: () => { + if (fieldSortedAsc){ + this.setColumnSort(undefined); + this.highlightSortedColumn(); + } else { + this.sortDocs(this.columnKeys[index], false); + this.setColumnSort(this.columnKeys[index], false); + } + }, + icon: fieldSortedAsc ? 'lock' : 'lock-open'}); // prettier-ignore + sortOptions.push({ + description: 'Persistent Sort Z-A', + event: () => { + if (fieldSortedDesc){ + this.setColumnSort(undefined); + this.highlightSortedColumn(); + } else { + this.sortDocs(this.columnKeys[index], true); + this.setColumnSort(this.columnKeys[index], true); + } + }, + icon: fieldSortedDesc ? 'lock' : 'lock-open'}); // prettier-ignore + + cm.addItem({ + description: `Change field`, event: () => this.openColumnMenu(index, false), icon: 'pencil-alt', }); - ContextMenu.Instance.addItem({ + cm.addItem({ description: 'Filter field', event: () => this.openFilterMenu(index), icon: 'filter', }); - ContextMenu.Instance.addItem({ + cm.addItem({ + description: 'Sort column', + addDivider: false, + noexpand: true, + subitems: sortOptions, + icon: 'sort' + }); + cm.addItem({ + description: 'Add column to left', + event: () => this.addColumn(index), + icon: 'plus', + }); + cm.addItem({ + description: 'Add column to right', + event: () => this.addColumn(index + 1), + icon: 'plus', + }); + cm.addItem({ description: 'Delete column', event: () => this.removeColumn(index), icon: 'trash', }); - ContextMenu.Instance.displayMenu(x, y, undefined, false); + cm.displayMenu(x, y, undefined, false); }; @action - updateKeySearch = (e: React.ChangeEvent<HTMLInputElement>) => { - this._menuValue = e.target.value; - this._menuKeys = this.documentKeys.filter(value => value.toLowerCase().includes(this._menuValue.toLowerCase())); + updateKeySearch = (val: string) => { + this._menuKeys = this.documentKeys.filter(value => value.toLowerCase().includes(val.toLowerCase())); }; getFieldFilters = (field: string) => StrListCast(this.Document._childFilters).filter(filter => filter.split(Doc.FilterSep)[0] === field); @@ -795,64 +1075,64 @@ export class CollectionSchemaView extends CollectionSubView() { this._filterSearchValue = e.target.value; }; - @computed get newFieldMenu() { - return ( - <div className="schema-new-key-options"> - <div className="schema-key-type-option"> - <input - type="radio" - name="newFieldType" - checked={this._newFieldType === ColumnType.Number} - onChange={action(() => { - this._newFieldType = ColumnType.Number; - this._newFieldDefault = 0; - })} - /> - number - </div> - <div className="schema-key-type-option"> - <input - type="radio" - name="newFieldType" - checked={this._newFieldType === ColumnType.Boolean} - onChange={action(() => { - this._newFieldType = ColumnType.Boolean; - this._newFieldDefault = false; - })} - /> - boolean - </div> - <div className="schema-key-type-option"> - <input - type="radio" - name="newFieldType" - checked={this._newFieldType === ColumnType.String} - onChange={action(() => { - this._newFieldType = ColumnType.String; - this._newFieldDefault = ''; - })} - /> - string - </div> - <div className="schema-key-default-val">value: {this.fieldDefaultInput}</div> - <div className="schema-key-warning">{this._newFieldWarning}</div> - <div - className="schema-column-menu-button" - onPointerDown={action(() => { - if (this.documentKeys.includes(this._menuValue)) { - this._newFieldWarning = 'Field already exists'; - } else if (this._menuValue.length === 0) { - this._newFieldWarning = 'Field cannot be an empty string'; - } else { - this.setKey(this._menuValue, this._newFieldDefault); - } - this._columnMenuIndex = undefined; - })}> - done - </div> - </div> - ); - } + // @computed get newFieldMenu() { + // return ( + // <div className="schema-new-key-options"> + // <div className="schema-key-type-option"> + // <input + // type="radio" + // name="newFieldType" + // checked={this._newFieldType === ColumnType.Number} + // onChange={action(() => { + // this._newFieldType = ColumnType.Number; + // this._newFieldDefault = 0; + // })} + // /> + // number + // </div> + // <div className="schema-key-type-option"> + // <input + // type="radio" + // name="newFieldType" + // checked={this._newFieldType === ColumnType.Boolean} + // onChange={action(() => { + // this._newFieldType = ColumnType.Boolean; + // this._newFieldDefault = false; + // })} + // /> + // boolean + // </div> + // <div className="schema-key-type-option"> + // <input + // type="radio" + // name="newFieldType" + // checked={this._newFieldType === ColumnType.String} + // onChange={action(() => { + // this._newFieldType = ColumnType.String; + // this._newFieldDefault = ''; + // })} + // /> + // string + // </div> + // <div className="schema-key-default-val">value: {this.fieldDefaultInput}</div> + // <div className="schema-key-warning">{this._newFieldWarning}</div> + // <div + // className="schema-column-menu-button" + // onPointerDown={action(() => { + // if (this.documentKeys.includes(this._menuValue)) { + // this._newFieldWarning = 'Field already exists'; + // } else if (this._menuValue.length === 0) { + // this._newFieldWarning = 'Field cannot be an empty string'; + // } else { + // this.setKey(this._menuValue, this._newFieldDefault); + // } + // this._columnMenuIndex = undefined; + // })}> + // done + // </div> + // </div> + // ); + // } onKeysPassiveWheel = (e: WheelEvent) => { // if scrollTop is 0, then don't let wheel trigger scroll on any container (which it would since onScroll won't be triggered on this) @@ -864,14 +1144,6 @@ export class CollectionSchemaView extends CollectionSubView() { return ( <div className="schema-key-search"> <div - className="schema-column-menu-button" - onPointerDown={action((e: any) => { - e.stopPropagation(); - this._makeNewField = true; - })}> - + new field - </div> - <div className="schema-key-list" ref={r => { this._oldKeysWheel?.removeEventListener('wheel', this.onKeysPassiveWheel); @@ -888,11 +1160,8 @@ export class CollectionSchemaView extends CollectionSubView() { <p> <span className="schema-search-result-key"> <b>{key}</b> - {this.fieldInfos.get(key)!.fieldType ? ':' : ''} - </span> - <span className="schema-search-result-type" style={{ color: this.fieldInfos.get(key)!.readOnly ? 'red' : 'inherit' }}> - {this.fieldInfos.get(key)!.fieldType} </span> + <span>: </span> <span className="schema-search-result-desc"> {this.fieldInfos.get(key)!.description}</span> </p> </div> @@ -905,17 +1174,8 @@ export class CollectionSchemaView extends CollectionSubView() { @computed get renderColumnMenu() { const x = this._columnMenuIndex! === -1 ? 0 : this.displayColumnWidths.reduce((total, curr, index) => total + (index < this._columnMenuIndex! ? curr : 0), CollectionSchemaView._rowMenuWidth); return ( - <div className="schema-column-menu" style={{ left: x, minWidth: CollectionSchemaView._minColWidth }}> - <input className="schema-key-search-input" type="text" onKeyDown={this.onSearchKeyDown} onChange={this.updateKeySearch} onPointerDown={e => e.stopPropagation()} /> - {this._makeNewField ? this.newFieldMenu : this.keysDropdown} - </div> - ); - } - get renderKeysMenu() { - return ( - <div className="schema-column-menu" style={{ left: 0, minWidth: CollectionSchemaView._minColWidth }}> - <input className="schema-key-search-input" type="text" onKeyDown={this.onSearchKeyDown} onChange={this.updateKeySearch} onPointerDown={e => e.stopPropagation()} /> - {this._makeNewField ? this.newFieldMenu : this.keysDropdown} + <div className="schema-column-menu" style={{ left: x, maxWidth: `${Math.max(this._colEles[this._columnMenuIndex ?? 0].offsetWidth, 150)}px` }}> + {this.keysDropdown} </div> ); } @@ -941,7 +1201,7 @@ export class CollectionSchemaView extends CollectionSubView() { } return ( <div key={key} className="schema-filter-option"> - <input // + <input type="checkbox" onPointerDown={e => e.stopPropagation()} onClick={e => e.stopPropagation()} @@ -957,7 +1217,7 @@ export class CollectionSchemaView extends CollectionSubView() { @computed get renderFilterMenu() { const x = this.displayColumnWidths.reduce((total, curr, index) => total + (index < this._filterColumnIndex! ? curr : 0), CollectionSchemaView._rowMenuWidth); return ( - <div className="schema-filter-menu" style={{ left: x, minWidth: CollectionSchemaView._minColWidth }}> + <div className="schema-filter-menu" style={{ left: x, maxWidth: `${Math.max(this._colEles[this._columnMenuIndex ?? 0].offsetWidth, 150)}px`}}> <input className="schema-filter-input" type="text" value={this._filterSearchValue} onKeyDown={this.onFilterKeyDown} onChange={this.updateFilterSearch} onPointerDown={e => e.stopPropagation()} /> {this.renderFilterOptions} <div @@ -972,51 +1232,112 @@ export class CollectionSchemaView extends CollectionSubView() { ); } + @action setColDrag = (beingDragged: boolean) => { + this._colBeingDragged = beingDragged; + !beingDragged && this.removeDragHighlight(); + } + + @action updateMouseCoordinates = (e: React.PointerEvent<HTMLDivElement>) => { + const prevX = this._mouseCoordinates.x; + const prevY = this._mouseCoordinates.y; + this._mouseCoordinates = { x: e.clientX, y: e.clientY, prevX: prevX, prevY: prevY }; + } + @action onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { if (DragManager.docsBeingDragged.length) { - this._mouseCoordinates = { x: e.clientX, y: e.clientY }; + this.updateMouseCoordinates(e); } if (this._colBeingDragged) { + this.updateMouseCoordinates(e); const newIndex = this.findColDropIndex(e.clientX); - if (newIndex !== this._draggedColIndex) this.moveColumn(this._draggedColIndex, newIndex ?? this._draggedColIndex); - this._draggedColIndex = newIndex || this._draggedColIndex; - this.highlightDraggedColumn(newIndex ?? this._draggedColIndex); + const direction: number = this._mouseCoordinates.x > this._mouseCoordinates.prevX ? 1 : 0; + if (newIndex !== undefined && ((newIndex > this._draggedColIndex && direction === 1) || (newIndex < this._draggedColIndex && direction === 0))) { + this.moveColumn(this._draggedColIndex, newIndex ?? this._draggedColIndex); + this._draggedColIndex = newIndex !== undefined ? newIndex : this._draggedColIndex; + } + this.highlightSortedColumn(); //TODO: Make this more efficient + this.restoreCellHighlights(); + !(this.sortField && this._draggedColIndex === this.columnKeys.indexOf(this.sortField)) && this.highlightDraggedColumn(this._draggedColIndex); } }; - @computed get sortedDocs() { - const draggedDocs = this.isContentActive() ? DragManager.docsBeingDragged : []; - const field = StrCast(this.layoutDoc.sortField); - const desc = BoolCast(this.layoutDoc.sortDesc); // is this an ascending or descending sort - const staticDocs = this.childDocs.filter(d => !draggedDocs.includes(d)); - const docs = !field - ? staticDocs - : [...staticDocs].sort((docA, docB) => { - // this sorts the documents based on the selected field. returning -1 for a before b, 0 for a = b, 1 for a > b - const aStr = Field.toString(docA[field] as FieldType); - const bStr = Field.toString(docB[field] as FieldType); - let out = 0; - if (aStr < bStr) out = -1; - if (aStr > bStr) out = 1; - if (desc) out *= -1; - return out; - }); - - docs.splice(this.rowDropIndex, 0, ...draggedDocs); + subCollectionDocs = (doc: Doc, displayed: boolean) => { + const childDocs = DocListCast(doc[Doc.LayoutFieldKey(doc)]); + let collections: Array<Doc> = []; + if (displayed) collections = childDocs.filter(d => d.type === 'collection' && d._childrenSharedWithSchema); + else collections = childDocs.filter(d => d.type === 'collection' && !d._childrenSharedWithSchema); + let toReturn: Doc[] = [...childDocs]; + collections.forEach(d => toReturn = toReturn.concat(this.subCollectionDocs(d, displayed))); + return toReturn; + } + + @computed get docs() { + let docsFromChildren: Doc[] = []; + + const displayedCollections = this.childDocs.filter(d => d.type === 'collection' && d._childrenSharedWithSchema); + displayedCollections.forEach(d => { + let docsNotAlreadyDisplayed = this.subCollectionDocs(d, true).filter(dc => !this._docs.includes(dc)); + docsFromChildren = docsFromChildren.concat(docsNotAlreadyDisplayed); + }); + let docs = this._docs.concat(docsFromChildren); + + return docs; + } + + sortDocs = (field: string, desc: boolean, persistent?: boolean) => { + const numbers: Doc[] = []; + const strings: Doc[] = []; + + this._docs.forEach(doc => { + if (!isNaN(Number(Field.toString(doc[field] as FieldType)))) numbers.push(doc); + else strings.push(doc); + }); + + const sortedNums = numbers.sort((numOne, numTwo) => { + const numA = Number(Field.toString(numOne[field] as FieldType)); + const numB = Number(Field.toString(numTwo[field] as FieldType)); + return desc? numA - numB : numB - numA; + }); + + const collator = new Intl.Collator(undefined, {sensitivity: 'base'}); + let sortedStrings; + if (!desc) {sortedStrings = strings.slice().sort((docA, docB) => collator.compare(Field.toString(docA[field] as FieldType), Field.toString(docB[field] as FieldType))); + } else sortedStrings = strings.slice().sort((docB, docA) => collator.compare(Field.toString(docA[field] as FieldType), Field.toString(docB[field] as FieldType))); + + const sortedDocs = desc ? sortedNums.concat(sortedStrings) : sortedStrings.concat(sortedNums); + if (!persistent) this._docs = sortedDocs; + return sortedDocs; + } + + @computed get docsWithDrag() { + let docs = this.docs.slice(); + if (this.sortField){ + const field = StrCast(this.layoutDoc.sortField); + const desc = BoolCast(this.layoutDoc.sortDesc); // is this an ascending or descending sort + docs = this.sortDocs(field, desc, true); + } else { + const draggedDocs = this.isContentActive() ? DragManager.docsBeingDragged.filter(doc => !(doc.type === 'fonticonbox')) : []; + docs = docs.filter(d => !draggedDocs.includes(d)); + docs.splice(this.rowDropIndex, 0, ...draggedDocs); + } + return { docs }; } rowHeightFunc = () => (BoolCast(this.layoutDoc._schema_singleLine) ? CollectionSchemaView._rowSingleLineHeight : CollectionSchemaView._rowHeight); - sortedDocsFunc = () => this.sortedDocs; isContentActive = () => this._props.isSelected() || this._props.isContentActive(); screenToLocal = () => this.ScreenToLocalBoxXf().translate(-this.tableWidth, 0); previewWidthFunc = () => this.previewWidth; onPassiveWheel = (e: WheelEvent) => e.stopPropagation(); + displayedDocsFunc = () => this.docsWithDrag.docs; _oldWheel: any; render() { return ( - <div className="collectionSchemaView" ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)} onDrop={this.onExternalDrop.bind(this)} onPointerMove={e => this.onPointerMove(e)}> + <div className="collectionSchemaView" ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)} + onDrop={this.onExternalDrop.bind(this)} + onPointerMove={e => this.onPointerMove(e)} + onPointerDown={() => {this.closeColumnMenu(); this.setColDrag(false)}}> <div ref={this._menuTarget} style={{ background: 'red', top: 0, left: 0, position: 'absolute', zIndex: 10000 }} /> <div className="schema-table" @@ -1029,26 +1350,38 @@ export class CollectionSchemaView extends CollectionSubView() { }}> <div className="schema-header-row" style={{ height: this.rowHeightFunc() }}> <div className="row-menu" style={{ width: CollectionSchemaView._rowMenuWidth }}> - <Popup - placement="right" - background={SettingsManager.userBackgroundColor} - color={SettingsManager.userColor} - toggle={<FontAwesomeIcon onPointerDown={() => this.openColumnMenu(-1, true)} icon="plus" />} - trigger={PopupTrigger.CLICK} - type={Type.TERT} - isOpen={this._columnMenuIndex !== -1 ? false : undefined} - popup={this.renderKeysMenu} + <IconButton + tooltip="Add a new key" + icon={ <FontAwesomeIcon icon="plus" size='lg'/>} + size={Size.XSMALL} + color={'black'} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + this.addColumn() + }, 'add key to schema') + ) + } /> </div> {this.columnKeys.map((key, index) => ( <SchemaColumnHeader // eslint-disable-next-line react/no-array-index-key + //cleanupField={this.cleanupComputedField} + ref={r => r && this._headerRefs.push(r)} + keysDropdown={(this.keysDropdown)} + schemaView={this} + columnWidth={() => CollectionSchemaView._minColWidth} //TODO: update + Document={this.Document} key={index} columnIndex={index} columnKeys={this.columnKeys} columnWidths={this.displayColumnWidths} - sortField={this.sortField} - sortDesc={this.sortDesc} setSort={this.setColumnSort} rowHeight={this.rowHeightFunc} removeColumn={this.removeColumn} @@ -1066,7 +1399,7 @@ export class CollectionSchemaView extends CollectionSubView() { // eslint-disable-next-line no-use-before-define <CollectionSchemaViewDocs schema={this} - childDocs={this.sortedDocsFunc} + childDocs={this.displayedDocsFunc} rowHeight={this.rowHeightFunc} setRef={(ref: HTMLDivElement | null) => { this._tableContentRef = ref; @@ -1189,7 +1522,7 @@ class CollectionSchemaViewDoc extends ObservableReactComponent<CollectionSchemaV interface CollectionSchemaViewDocsProps { schema: CollectionSchemaView; setRef: (ref: HTMLDivElement | null) => void; - childDocs: () => { docs: Doc[] }; + childDocs: () => Doc[]; rowHeight: () => number; } @@ -1198,7 +1531,7 @@ class CollectionSchemaViewDocs extends React.Component<CollectionSchemaViewDocsP render() { return ( <div className="schema-table-content" ref={this.props.setRef} style={{ height: `calc(100% - ${CollectionSchemaView._newNodeInputHeight + this.props.rowHeight()}px)` }}> - {this.props.childDocs().docs.map((doc: Doc, index: number) => ( + {this.props.childDocs().map((doc: Doc, index: number) => ( <div key={doc[Id]} className="schema-row-wrapper" style={{ height: this.props.rowHeight() }}> <CollectionSchemaViewDoc doc={doc} schema={this.props.schema} index={index} rowHeight={this.props.rowHeight} /> </div> diff --git a/src/client/views/collections/collectionSchema/SchemaCellField.tsx b/src/client/views/collections/collectionSchema/SchemaCellField.tsx new file mode 100644 index 000000000..3be9167fe --- /dev/null +++ b/src/client/views/collections/collectionSchema/SchemaCellField.tsx @@ -0,0 +1,491 @@ +import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; +import { ObservableReactComponent } from "../../ObservableReactComponent"; +import { observer } from "mobx-react"; +import { OverlayView } from "../../OverlayView"; +import { DocumentIconContainer } from "../../nodes/DocumentIcon"; +import React, { FormEvent } from "react"; +import { FieldView, FieldViewProps } from "../../nodes/FieldView"; +import { ObjectField } from "../../../../fields/ObjectField"; +import { Doc } from "../../../../fields/Doc"; +import { DocumentView } from "../../nodes/DocumentView"; +import { date } from "serializr"; +import { createRoot } from "react-dom/client"; +import DatePicker from "react-datepicker"; +import { emptyFunction } from "../../../../Utils"; +import { DateField } from "../../../../fields/DateField"; + +enum EleType { + plainText, fieldReference, date, boolean +} + +export interface SchemaCellFieldProps { + contents: any; + fieldContents?: FieldViewProps; + editing?: boolean; + oneLine?: boolean; + Document: Doc; + fieldKey: string; + refSelectModeInfo: {enabled: boolean, currEditing: SchemaCellField | undefined}; + highlightCells?: (text: string) => void; + GetValue(): string | undefined; + SetValue(value: string, shiftDown?: boolean, enterKey?: boolean): boolean; + getCells: (text: string) => HTMLDivElement[] | []; +} + +@observer +export class SchemaCellField extends ObservableReactComponent<SchemaCellFieldProps> { + + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _inputref: HTMLDivElement | null = null; + private _unrenderedContent: string = ''; + _overlayDisposer?: () => void; + @observable _editing: boolean = false; + @observable _displayedContent = ''; + @observable _inCellSelectMode: boolean = false; + @observable _dependencyMessageShown: boolean = false; + @observable _displayedElements: JSX.Element[] = []; + + constructor(props: SchemaCellFieldProps) { + super(props); + makeObservable(this); + setTimeout(() => { + this._unrenderedContent = this._props.GetValue() ?? ''; + this.setContent(this._unrenderedContent); + }); //must be moved to end of batch or else other docs aren't loaded, so render as d-1 in function + } + + get docIndex(){return DocumentView.getDocViewIndex(this._props.Document);} // prettier-ignore + + get selfRefPattern() {return `d${this.docIndex}.${this._props.fieldKey}`}; + + @computed get lastCharBeforeCursor(){ + const pos = this.cursorPosition; + const content = this._unrenderedContent; + const text = this._unrenderedContent.substring(0, pos ?? content.length); + for (let i = text.length - 1; i > 0; --i) { + if (text.charCodeAt(i) !== 160 && text.charCodeAt(i) !== 32) { + return text[i]; + } + } + return null; + } + + @computed get refSelectConditionMet() { + const char = this.lastCharBeforeCursor; + return char === '+' || char === '*' || char === '/' || char === '%' || char === '='; + } + + componentDidMount(): void { + this._unrenderedContent = this._props.GetValue() ?? ''; + this.setContent(this._unrenderedContent, true); + this._disposers.editing = reaction( + () => this._editing, + editing => { + if (editing) { + this.setupRefSelect(this.refSelectConditionMet); + setTimeout(() => { + if (this._inputref?.innerText.startsWith('=') || this._inputref?.innerText.startsWith(':=')) { + this._overlayDisposer?.(); + this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + this._props.highlightCells?.(this._unrenderedContent); + this.setContent(this._unrenderedContent); + setTimeout(() => this.setCursorPosition(this._unrenderedContent.length)); + } + }); + } else { + this._overlayDisposer?.(); + this._overlayDisposer = undefined; + this._props.highlightCells?.(''); + this.setupRefSelect(false); + } + }, + { fireImmediately: true } + ); + this._disposers.fieldUpdate = reaction( + () => this._props.GetValue(), + fieldVal => { + this._unrenderedContent = fieldVal ?? ''; + this.finalizeEdit(false, false, false); + } + ) + } + + componentDidUpdate(prevProps: Readonly<SchemaCellFieldProps>) { + super.componentDidUpdate(prevProps); + if (this._editing && this._props.editing === false) { + this.finalizeEdit(false, true, false); + } else + runInAction(() => { + if (this._props.editing !== undefined) this._editing = this._props.editing; + }); + } + + componentWillUnmount(): void { + this._overlayDisposer?.(); + Object.values(this._disposers).forEach(disposer => disposer?.()); + this.finalizeEdit(false, true, false); + } + + // addReactComponents = () => { + // if (!this._inputref) return; + + // const dateRefs = Array.from(this._inputref.querySelectorAll('.date-placeholder')); + + // dateRefs.forEach(ref => { + // const root = createRoot(ref); + // root.render(<DatePicker dateFormat="Pp" selected={new Date} onChange={emptyFunction} />); + // }) + // } + + generateSpan = (text: string, cell: HTMLDivElement | undefined): JSX.Element => { + const selfRef = text === this.selfRefPattern; + const color: string | undefined = cell?.style.borderTop.replace('2px solid', ''); + return ( + <span style={{ + textDecoration: selfRef ? 'underline' : 'none', + textDecorationColor: 'red', + color: selfRef ? 'gray' : color}}> + {text} + </span> + ); + } + + makeSpans = (content: string) => { + const spans: JSX.Element[] = []; + let chunkedText = content; + + const pattern = /(this|d(\d+))\.(\w+)/g; + const matches: string[] = []; + let match: RegExpExecArray | null; + + const cells: Map<string, HTMLDivElement> = new Map(); + + while ((match = pattern.exec(content)) !== null) { + const cell = this._props.getCells(match[0]); + if (cell.length) { + matches.push(match[0]); + cells.set(match[0], cell[0]) + } + } + + let matchNum = 0; + matches.forEach((match: string) => { + chunkedText = chunkedText.replace(match, ''); + spans.push(this.generateSpan(match, cells.get(match))); + ++matchNum; + }) + + chunkedText = chunkedText.replace(/{{date}}/g, `<span class="date-placeholder">placeholder text</span>`); + + return chunkedText; + } + + verifyCellRef = (text: string): [string, HTMLDivElement | undefined] | undefined => { + const pattern = /(this|d(\d+))\.(\w+)/g; + let matchedText: string = ''; + let matchedCell: HTMLDivElement | undefined = undefined; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) !== null) { + const cells = this._props.getCells(match[0]); + if (cells.length) { + matchedText = match[0]; + matchedCell = cells[0]; + } + } + + if (!matchedText && !matchedCell) return undefined; + else return [matchedText, matchedCell]; + } + + elementsFromText = (chunks: string[]): JSX.Element[] => { + const eles: any[] = []; + + chunks.forEach(text => { + const cellRef = this.verifyCellRef(text); + if (cellRef) { + eles.push(this.generateSpan(cellRef[0], cellRef[1])); + } else if (text && !text.replace('{{date}}', '')){ + eles.push(<DatePicker dateFormat="Pp" selected={new Date()} onChange={emptyFunction} />); + } else if (text && !text.replace('{{boolean}}', '')) { + eles.push(<span>boolean thing</span>); + } else { + eles.push(<span>{text}</span>); + } + }); + + return eles; + } + + parseElements = (content: string) => { + let string: string = content; + if (string.startsWith(':')) string = string.slice(1); + if (string.startsWith('=')) string = string.slice(1); + + const chunks: string[] = []; + + let subStr: string = ''; + let currChar = ''; + for (let i = 0; i < string.length; i++){ + currChar = string[i]; + if (((string.charCodeAt(i) === 32 || string.charCodeAt(i) === 160) && subStr.trim()) || (currChar !== ' ' && !subStr.trim())) { + chunks.push(subStr); + subStr = currChar; + } else { + subStr += currChar; + } + } + + if (subStr) {chunks.push(subStr)}; + + return this.elementsFromText(chunks); + } + + // `<input + // style={{ marginRight: 4 }} + // type="checkbox" + // checked={BoolCast(this._props.Document[this._props.fieldKey])} + // onChange={undoBatch((value: React.ChangeEvent<HTMLInputElement> | undefined) => { + // if ((value?.nativeEvent as any).shiftKey) { + // this._props.setColumnValues(this._props.fieldKey.replace(/^_/, ''), (color === 'black' ? '=' : '') + (value?.target?.checked.toString() ?? '')); + // } else Doc.SetField(this._props.Document, this._props.fieldKey.replace(/^_/, ''), (color === 'black' ? '=' : '') + (value?.target?.checked.toString() ?? '')); + // })} + // />` + + @action + setContent = (content: string, restoreCursorPos?: boolean) => { + const pos = this.cursorPosition; + this._displayedContent = content; + this._displayedElements = this.parseElements(content); + restoreCursorPos && setTimeout(() => this.setCursorPosition(pos)); + } + + @action + appendText = (text: string, atCursorPos?: boolean) => { + const content = this._unrenderedContent; + const cursorPos = this.cursorPosition; + const robustPos = cursorPos ?? content.length; + const newText = atCursorPos ? content.slice(0, robustPos) + text + content.slice(cursorPos ?? content.length) : this._unrenderedContent.concat(text); + this.onChange(undefined, newText); + setTimeout(() => this.setCursorPosition(robustPos + text.length)); + } + + @action + setIsFocused = (value: boolean) => { + const wasFocused = this._editing; + this._editing = value; + return wasFocused !== this._editing; + }; + + get cursorPosition() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !this._inputref) return null; + + const range = selection.getRangeAt(0); + const adjRange = range.cloneRange(); + + adjRange.selectNodeContents(this._inputref); + adjRange.setEnd(range.startContainer, range.startOffset); + + return adjRange.toString().length; + } + + setCursorPosition = (position: number | null) => { + const selection = window.getSelection(); + if (!selection || position === null || !this._inputref) return; + + const range = document.createRange(); + range.setStart(this._inputref, 0); + range.collapse(true); + + let currentPos = 0; + const setRange = (nodes: NodeList) => { + for (let i = 0; i < nodes.length; ++i) { + const node = nodes[i]; + + if (node.nodeType === Node.TEXT_NODE) { + if (!node.textContent) return; + const nextPos = currentPos + node.textContent.length; + if (position <= nextPos) { + range.setStart(node, position - currentPos); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return true; + } + currentPos = nextPos; + + } else if ((node.nodeType === Node.ELEMENT_NODE) && (setRange(node.childNodes))) return true; + } + return false; + } + + setRange(this._inputref.childNodes); + }; + + shouldUpdate = (prevVal: string, currVal: string) => { + if (this._props.getCells(currVal).length !== this._props.getCells(prevVal).length) return true; + //if (contains self-ref pattern) + }; + + onChange = (e: FormEvent<HTMLDivElement> | undefined, newText?: string) => { + const prevVal = this._unrenderedContent; + const targVal = newText ?? e!.currentTarget.innerText; // TODO: bang + if (!(targVal.startsWith(':=') || targVal.startsWith('='))) { + this._overlayDisposer?.(); + this._overlayDisposer = undefined; + } else if (!this._overlayDisposer) { + this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + } + this._unrenderedContent = targVal; + this._props.highlightCells?.(targVal); + if (this.parseElements(targVal).length > this.parseElements(this._displayedContent).length) this.setContent(targVal, true); + this.setupRefSelect(this.refSelectConditionMet); + console.log(this.parseElements(targVal)); + }; + + setupRefSelect = (enabled: boolean) => { + const properties = this._props.refSelectModeInfo; + properties.enabled = enabled; + properties.currEditing = enabled ? this : undefined; + } + + @action + onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.nativeEvent.defaultPrevented) return; // hack .. DashFieldView grabs native events, but react ignores stoppedPropagation and preventDefault, so we need to check it here + // if (e.metaKey) { + // e.stopPropagation(); + // e.preventDefault(); + // } + switch (e.key) { + case 'Tab': + e.stopPropagation(); + this.finalizeEdit(e.shiftKey, false, false); + break; + case 'Backspace': + e.stopPropagation(); + break; + case 'Enter': + e.stopPropagation(); + if (!e.ctrlKey) { + this.finalizeEdit(e.shiftKey, false, true); + } + break; + case 'Escape': + e.stopPropagation(); + this._editing = false; + break; + case 'ArrowUp': case 'ArrowDown': case 'ArrowLeft': case 'ArrowRight': // prettier-ignore + e.stopPropagation(); + setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0) + break; + case ' ': + e.stopPropagation(); + let cursorPos = 0; + if (this.cursorPosition !== null) cursorPos = this.cursorPosition + 1; + setTimeout(() => { + this.setContent(this._unrenderedContent); + setTimeout(() => this.setCursorPosition(cursorPos)); + } + , 0); + break; + case 'u': // for some reason 'u' otherwise exits the editor + e.stopPropagation(); + break; + case 'Shift': case 'Alt': case 'Meta': case 'Control': case ':': // prettier-ignore + break; + // eslint-disable-next-line no-fallthrough + default: + break; + } + }; + + + @action + onClick = (e?: React.MouseEvent) => { + if (this._props.editing !== false) { + e?.nativeEvent.stopPropagation(); + this._editing = true; + } + }; + + @action + finalizeEdit(shiftDown: boolean, lostFocus: boolean, enterKey: boolean) { + if (this._unrenderedContent.replace(this.selfRefPattern, '') !== this._unrenderedContent) { + this._dependencyMessageShown ? this._dependencyMessageShown = false : + alert(`Circular dependency detected. Please update the field at ${this.selfRefPattern}.`) + this._dependencyMessageShown = true; + return; + } + + if (this._props.SetValue(this._unrenderedContent, shiftDown, enterKey)) { + this._editing = false; + } else { + this._editing = false; + !lostFocus && + setTimeout( + action(() => { + this._editing = true; + }), + 0 + ); + } + } + + //<FieldView {...this._props.fieldContents}/> + + staticDisplay = () => { + return <span className='editableView-static'> + { + // eslint-disable-next-line react/jsx-props-no-spreading + this._props.fieldContents ? <FieldView {...this._props.fieldContents}/> : '' + } + </span> + } + + renderEditor = () => { + return ( + <div + contentEditable + className='schemaField-editing' + ref={r => { this._inputref = r; }} + style={{ cursor: 'text', outline: 'none', overflow: 'auto', minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20, }} + onBlur={e => {this._props.refSelectModeInfo.enabled ? setTimeout(() => {this.setIsFocused(true)}, 1000) : this.finalizeEdit(false, true, false)}} + autoFocus + onInput={this.onChange} + onKeyDown={this.onKeyDown} + onPointerDown={e => {e.stopPropagation(); setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0)}} //timeout callback ensures that refSelectMode is properly set + onClick={e => e.stopPropagation} + onPointerUp={e => e.stopPropagation} + onPointerMove={e => {e.stopPropagation(); e.preventDefault()}} + suppressContentEditableWarning={true} + //dangerouslySetInnerHTML={{ __html: this._displayedContent }} + > + {this._displayedElements.map((ele, index) => { + return <span key={index}>{ele}</span>; + })} + </div> + ); + } + + render() { + const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n'); + if ((this._editing && gval !== undefined)) { + return <div className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`}>{this.renderEditor()}</div>; + } else return ( + this._props.contents instanceof ObjectField ? null : ( + <div + className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`} + style={{ + minHeight: '10px', + whiteSpace: this._props.oneLine ? 'nowrap' : 'pre-line', + width: '100%', + }} + onClick={this.onClick}> + {this.staticDisplay()} + </div> + ) + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx index 6b5a34ec0..40509c41b 100644 --- a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx +++ b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx @@ -1,78 +1,252 @@ /* eslint-disable react/no-unused-prop-types */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action } from 'mobx'; +import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { setupMoveUpEvents } from '../../../../ClientUtils'; +import { returnEmptyDoclist, returnEmptyFilter, returnFalse, returnZero, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; import { Colors } from '../../global/globalEnums'; import './CollectionSchemaView.scss'; +import { EditableView } from '../../EditableView'; +import { ObservableReactComponent } from '../../ObservableReactComponent'; +import { DefaultStyleProvider, returnEmptyDocViewList } from '../../StyleProvider'; +import { FieldViewProps } from '../../nodes/FieldView'; +import { Doc, Field } from '../../../../fields/Doc'; +import { dropActionType } from '../../../util/DropActionTypes'; +import { Transform } from '../../../util/Transform'; +import { SchemaTableCell } from './SchemaTableCell'; +import { DocCast } from '../../../../fields/Types'; +import { computedFn } from 'mobx-utils'; +import { CollectionSchemaView } from './CollectionSchemaView'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { undoable } from '../../../util/UndoManager'; +import { FInfo } from '../../../documents/Documents'; +import { ColumnType } from '../../../../fields/SchemaHeaderField'; +import { IconButton, Size } from 'browndash-components'; + +export enum SchemaFieldType { + Header, Cell +} export interface SchemaColumnHeaderProps { + Document: Doc; + autoFocus?: boolean; columnKeys: string[]; columnWidths: number[]; columnIndex: number; - sortField: string; - sortDesc: boolean; + schemaView: CollectionSchemaView; + keysDropdown: React.JSX.Element; + //cleanupField: (s: string) => string; isContentActive: (outsideReaction?: boolean | undefined) => boolean | undefined; setSort: (field: string | undefined, desc?: boolean) => void; removeColumn: (index: number) => void; rowHeight: () => number; - resizeColumn: (e: any, index: number) => void; + resizeColumn: (e: any, index: number, isRight: boolean) => void; dragColumn: (e: any, index: number) => boolean; openContextMenu: (x: number, y: number, index: number) => void; setColRef: (index: number, ref: HTMLDivElement) => void; + rootSelected?: () => boolean; + columnWidth: () => number; + finishEdit?: () => void; // notify container that edit is over (eg. to hide view in DashFieldView) + //transform: () => Transform; } @observer -export class SchemaColumnHeader extends React.Component<SchemaColumnHeaderProps> { - get fieldKey() { - return this.props.columnKeys[this.props.columnIndex]; +export class SchemaColumnHeader extends ObservableReactComponent<SchemaColumnHeaderProps> { + + private _inputRef: EditableView | null = null; + @observable _altTitle: string | undefined = undefined; + @observable _showMenuIcon: boolean = false; + + @computed get fieldKey() { + return this._props.columnKeys[this._props.columnIndex]; } - @action - sortClicked = (e: React.PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (this.props.sortField === this.fieldKey && this.props.sortDesc) { - this.props.setSort(undefined); - } else if (this.props.sortField === this.fieldKey) { - this.props.setSort(this.fieldKey, true); - } else { - this.props.setSort(this.fieldKey, false); - } + constructor(props: SchemaColumnHeaderProps){ + super(props); + makeObservable(this); + } + + getFinfo = computedFn((fieldKey: string) => this._props.schemaView?.fieldInfos.get(fieldKey)); + setColumnValues = (field: string, defaultValue: string) => {this._props.schemaView?.setKey(field, defaultValue, this._props.columnIndex);} + @action updateAlt = (newAlt: string) => {this._altTitle = newAlt}; + updateKeyDropdown = (value: string) => {this._props.schemaView.updateKeySearch(value)}; + openKeyDropdown = () => {!this._props.schemaView._colBeingDragged && this._props.schemaView.openColumnMenu(this._props.columnIndex, false)}; + toggleEditing = (editing: boolean) => { + this._inputRef?.setIsEditing(editing); + this._inputRef?.setIsFocused(editing); }; @action - onPointerDown = (e: React.PointerEvent) => { - this.props.isContentActive(true) && setupMoveUpEvents(this, e, moveEv => this.props.dragColumn(moveEv, this.props.columnIndex), emptyFunction, emptyFunction); + setupDrag = (e: React.PointerEvent) => { + this._props.isContentActive(true) && setupMoveUpEvents(this, e, moveEv => this._props.dragColumn(moveEv, this._props.columnIndex), emptyFunction, emptyFunction); }; + renderProps = (props: SchemaColumnHeaderProps) => { + const { columnKeys, columnWidth, Document } = props; + const fieldKey = columnKeys[props.columnIndex]; + const color = 'black'; + const fieldProps: FieldViewProps = { + childFilters: returnEmptyFilter, + childFiltersByRanges: returnEmptyFilter, + docViewPath: returnEmptyDocViewList, + searchFilterDocs: returnEmptyDoclist, + styleProvider: DefaultStyleProvider, + isSelected: returnFalse, + setHeight: returnFalse, + select: emptyFunction, + dragAction: dropActionType.move, + renderDepth: 1, + noSidebar: true, + isContentActive: returnFalse, + whenChildContentsActiveChanged: emptyFunction, + ScreenToLocalTransform: Transform.Identity, + focus: emptyFunction, + addDocTab: SchemaTableCell.addFieldDoc, + pinToPres: returnZero, + Document: DocCast(Document.rootDocument, Document), + fieldKey: fieldKey, + PanelWidth: columnWidth, + PanelHeight: props.rowHeight, + rootSelected: props.rootSelected, + }; + const readOnly = this.getFinfo(fieldKey)?.readOnly ?? false; + const cursor = !readOnly ? 'text' : 'default'; + const pointerEvents: 'all' | 'none' = 'all'; + return { color, fieldProps, cursor, pointerEvents }; + } + + @computed get editableView() { + const { color, fieldProps, pointerEvents } = this.renderProps(this._props); + + return <div className='schema-column-edit-wrapper' onPointerUp={() => { + SchemaColumnHeader.isDefaultField(this.fieldKey) && this.openKeyDropdown(); + this._props.schemaView.deselectAllCells(); + }} + style={{ + color, + width: '100%', + pointerEvents, + }}> + <EditableView + ref={r => {this._inputRef = r; this._props.autoFocus && r?.setIsFocused(true)}} + oneLine={true} + allowCRs={false} + contents={undefined} + onClick={this.openKeyDropdown} + fieldContents={fieldProps} + editing={undefined} + placeholder={'Add key'} + updateAlt={this.updateAlt} // alternate title to display + updateSearch={this.updateKeyDropdown} + schemaFieldType={SchemaFieldType.Header} + GetValue={() => { + if (SchemaColumnHeader.isDefaultField(this.fieldKey)) return ''; + else if (this._altTitle) return this._altTitle; + else return this.fieldKey; + }} + SetValue={undoable((value: string, shiftKey?: boolean, enterKey?: boolean) => { + if (enterKey) { // if shift & enter, set value of each cell in column + this.setColumnValues(value, ''); + this._altTitle = undefined; + this._props.finishEdit?.(); + return true; + } else if (enterKey) this.updateAlt(value); + this._props.finishEdit?.(); + return true; + }, 'edit column header')} + /> + </div> + } + + public static isDefaultField = (key: string) => { + const defaultPattern = /EmptyColumnKey/; + let isDefault: boolean = (defaultPattern.exec(key) != null); + return isDefault; + } + + get headerButton(){ + const toRender = SchemaColumnHeader.isDefaultField(this.fieldKey) ? + (<IconButton + icon={ <FontAwesomeIcon icon="trash" size='sm'/>} + size={Size.XSMALL} + color={'black'} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + this._props.schemaView.removeColumn(this._props.columnIndex); + }, 'open column menu') + ) + } + />) + : (<IconButton + icon={ <FontAwesomeIcon icon="caret-down" size='lg'/>} + size={Size.XSMALL} + color={'black'} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + this._props.openContextMenu(e.clientX, e.clientY, this._props.columnIndex) + }, 'open column menu') + ) + } + />) + + return toRender; + } + + @action handlePointerEnter = () => this._showMenuIcon = true; + @action handlePointerLeave = () => this._showMenuIcon = false; + + @computed get displayButton() {return this._showMenuIcon;} + render() { return ( - <div - className="schema-column-header" - style={{ - width: this.props.columnWidths[this.props.columnIndex], - }} - onPointerDown={this.onPointerDown} - ref={col => { - if (col) { - this.props.setColRef(this.props.columnIndex, col); + <div + className="schema-column-header" + style={{ + width: this._props.columnWidths[this._props.columnIndex], + }} + onPointerEnter={() => {this.handlePointerEnter()}} + onPointerLeave={() => {this.handlePointerLeave()}} + onPointerDown={e => { + this.setupDrag(e); + setupMoveUpEvents( + this, + e, + e => {return this._inputRef?.setIsEditing(false) ?? false}, + emptyFunction, + emptyFunction, + ); + } } - }}> - <div className="schema-column-resizer left" onPointerDown={e => this.props.resizeColumn(e, this.props.columnIndex)} /> - <div className="schema-column-title">{this.fieldKey}</div> - - <div className="schema-header-menu"> - <div className="schema-header-button" onPointerDown={e => this.props.openContextMenu(e.clientX, e.clientY, this.props.columnIndex)}> - <FontAwesomeIcon icon="ellipsis-h" /> - </div> - <div className="schema-sort-button" onPointerDown={this.sortClicked} style={this.props.sortField === this.fieldKey ? { backgroundColor: Colors.MEDIUM_BLUE } : {}}> - <FontAwesomeIcon icon="caret-right" style={this.props.sortField === this.fieldKey ? { transform: `rotate(${this.props.sortDesc ? '270deg' : '90deg'})` } : {}} /> - </div> + ref={col => { + if (col) { + this._props.setColRef(this._props.columnIndex, col); + } + }}> + <div className="schema-column-resizer left" onPointerDown={e => this._props.resizeColumn(e, this._props.columnIndex, false)} /> + + <div className="schema-header-text">{this.editableView}</div> + + <div className="schema-header-menu"> + <div className="schema-header-button" style={{opacity: this.displayButton ? '1.0' : '0.0'}}> + {this.headerButton} + </div> + </div> + + <div className="schema-column-resizer right" onPointerDown={e => this._props.resizeColumn(e, this._props.columnIndex, true)} /> </div> - </div> ); } } diff --git a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx index 760089ffb..72f5d8c25 100644 --- a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx +++ b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx @@ -1,13 +1,13 @@ import { IconButton, Size } from 'browndash-components'; -import { computed, makeObservable } from 'mobx'; +import { computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import { computedFn } from 'mobx-utils'; import * as React from 'react'; -import { CgClose, CgLock, CgLockUnlock } from 'react-icons/cg'; +import { CgClose, CgLock, CgLockUnlock, CgMenu } from 'react-icons/cg'; import { FaExternalLinkAlt } from 'react-icons/fa'; import { returnFalse, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; -import { Doc } from '../../../../fields/Doc'; +import { Doc, DocListCast, FieldResult } from '../../../../fields/Doc'; import { BoolCast } from '../../../../fields/Types'; import { Transform } from '../../../util/Transform'; import { undoable } from '../../../util/UndoManager'; @@ -18,6 +18,13 @@ import { OpenWhere } from '../../nodes/OpenWhere'; import { CollectionSchemaView } from './CollectionSchemaView'; import './CollectionSchemaView.scss'; import { SchemaTableCell } from './SchemaTableCell'; +import { ContextMenu } from '../../ContextMenu'; +import { CollectionFreeFormView } from '../collectionFreeForm'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { infoState } from '../collectionFreeForm/CollectionFreeFormInfoState'; +import { TbShieldX } from 'react-icons/tb'; +import { DocumentType } from '../../../documents/DocumentTypes'; interface SchemaRowBoxProps extends FieldViewProps { rowIndex: number; @@ -28,6 +35,7 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { return FieldView.LayoutString(SchemaRowBox, fieldKey).replace('fieldKey', `rowIndex={${rowIndex}} fieldKey`); } private _ref: HTMLDivElement | null = null; + @observable _childrenAddedToSchema: boolean = false; constructor(props: SchemaRowBoxProps) { super(props); @@ -52,21 +60,72 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { this._props.setContentViewBox?.(this); } + openContextMenu = (x: number, y: number) => { + ContextMenu.Instance.clearItems(); + ContextMenu.Instance.addItem({ + description: this.Document._lockedSchemaEditing ? 'Unlock field editing' : 'Lock field editing', + event: () => this.Document._lockedSchemaEditing = !this.Document._lockedSchemaEditing, + icon: this.Document._lockedSchemaEditing ? 'lock-open' : 'lock', + }); + ContextMenu.Instance.addItem({ + description: 'Open preview', + event: () => this._props.addDocTab(this.Document, OpenWhere.addRight), + icon: 'magnifying-glass', + }); + ContextMenu.Instance.addItem({ + description: `Close doc`, + event: () => this.schemaView.removeDoc(this.Document), + icon: 'minus', + }); + const childDocs = DocListCast(this.Document[Doc.LayoutFieldKey(this.Document)]) + if (childDocs.length) { + ContextMenu.Instance.addItem({ + description: this.Document._childrenSharedWithSchema ? 'Remove children from schema' : 'Add children to schema', + event: () => { + this.Document._childrenSharedWithSchema = !this.Document._childrenSharedWithSchema; + }, + icon: this.Document._childrenSharedWithSchema ? 'minus' : 'plus', + }); + } + ContextMenu.Instance.displayMenu(x, y, undefined, false); + } + + @computed get menuBackgroundColor(){ + if (this.Document._lockedSchemaEditing) {return '#F5F5F5'} + return '' + } + + @computed get menuInfos() { + const infos: Array<IconProp> = []; + if (this.Document._lockedSchemaEditing) infos.push('lock'); + if (this.Document._childrenSharedWithSchema) infos.push('star'); + return infos; + } + + isolatedSelection = (doc: Doc) => {return this.schemaView?.selectionOverlap(doc)}; setCursorIndex = (mouseY: number) => this.schemaView?.setRelCursorIndex(mouseY); selectedCol = () => this.schemaView._selectedCol; getFinfo = computedFn((fieldKey: string) => this.schemaView?.fieldInfos.get(fieldKey)); selectCell = (doc: Doc, col: number, shift: boolean, ctrl: boolean) => this.schemaView?.selectCell(doc, col, shift, ctrl); deselectCell = () => this.schemaView?.deselectAllCells(); selectedCells = () => this.schemaView?._selectedDocs; - setColumnValues = (field: any, value: any) => this.schemaView?.setColumnValues(field, value) ?? false; - setSelectedColumnValues = (field: any, value: any) => this.schemaView?.setSelectedColumnValues(field, value) ?? false; + setColumnValues = (field: any, value: any) => this.schemaView?.setCellValues(field, value) ?? false; columnWidth = computedFn((index: number) => () => this.schemaView?.displayColumnWidths[index] ?? CollectionSchemaView._minColWidth); + computeRowIndex = () => this.schemaView?.rowIndex(this.Document); + highlightCells = (text: string) => this.schemaView?.highlightCells(text); + selectReference = (doc: Doc, col: number) => {this.schemaView.selectReference(doc, col)} + eqHighlightFunc = (text: string) => { + const info = this.schemaView.findCellRefs(text); + const cells: HTMLDivElement[] = []; + info.forEach(info => {cells.push(this.schemaView.getCellElement(info[0], info[1]))}) + return cells; + }; render() { return ( <div className="schema-row" onPointerDown={e => this.setCursorIndex(e.clientY)} - style={{ height: this._props.PanelHeight(), backgroundColor: this._props.isSelected() ? Colors.LIGHT_BLUE : undefined }} + style={{ height: this._props.PanelHeight()}} ref={(row: HTMLDivElement | null) => { row && this.schemaView?.addRowRef?.(this.Document, row); this._ref = row; @@ -76,11 +135,13 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { style={{ width: CollectionSchemaView._rowMenuWidth, pointerEvents: !this._props.isContentActive() ? 'none' : undefined, + backgroundColor: this.menuBackgroundColor }}> <IconButton - tooltip="close" - icon={<CgClose size="16px" />} + tooltip="Open actions menu" + icon={ <FontAwesomeIcon icon="caret-right" size='lg'/>} size={Size.XSMALL} + color={'black'} onPointerDown={e => setupMoveUpEvents( this, @@ -89,50 +150,26 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { emptyFunction, undoable(clickEv => { clickEv.stopPropagation(); - this._props.removeDocument?.(this.Document); - }, 'Delete Row') - ) - } - /> - <IconButton - tooltip="whether document interactions are enabled" - icon={this.Document._lockedPosition ? <CgLockUnlock size="12px" /> : <CgLock size="12px" />} - size={Size.XSMALL} - onPointerDown={e => - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - Doc.toggleLockedPosition(this.Document); - }, 'toggle document lock') - ) - } - /> - <IconButton - tooltip="open preview" - icon={<FaExternalLinkAlt />} - size={Size.XSMALL} - onPointerDown={e => - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - this._props.addDocTab(this.Document, OpenWhere.addRight); - }, 'Open schema Doc preview') + this.openContextMenu(e.clientX, e.clientY) + }, 'open actions menu') ) } /> + <div className="row-menu-infos"> + {this.menuInfos.map(icn => <FontAwesomeIcon className="row-infos-icon" icon={icn} size='2xs' />)} + </div> </div> <div className="row-cells"> {this.schemaView?.columnKeys?.map((key, index) => ( <SchemaTableCell + selectReference={this.selectReference} + refSelectModeInfo={this.schemaView._referenceSelectMode} + eqHighlightFunc={this.eqHighlightFunc} + equationHighlightRef={this.schemaView._cellHighlightColors} + highlightCells={this.highlightCells} + isolatedSelection={this.isolatedSelection} key={key} + rowSelected={this._props.isSelected} Document={this.Document} col={index} fieldKey={key} @@ -146,7 +183,6 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { selectedCells={this.selectedCells} selectedCol={this.selectedCol} setColumnValues={this.setColumnValues} - setSelectedColumnValues={this.setSelectedColumnValues} oneLine={BoolCast(this.schemaDoc?._singleLine)} menuTarget={this.schemaView.MenuTarget} transform={() => { diff --git a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx index 5874364e0..7d29e40cc 100644 --- a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx +++ b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx @@ -3,7 +3,7 @@ /* eslint-disable no-use-before-define */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Popup, Size, Type } from 'browndash-components'; -import { action, computed, makeObservable, observable } from 'mobx'; +import { ObservableMap, action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import { extname } from 'path'; import * as React from 'react'; @@ -13,7 +13,7 @@ import Select from 'react-select'; import { ClientUtils, StopEvent, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnZero } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; import { DateField } from '../../../../fields/DateField'; -import { Doc, DocListCast, Field } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, IdToDoc } from '../../../../fields/Doc'; import { RichTextField } from '../../../../fields/RichTextField'; import { ColumnType } from '../../../../fields/SchemaHeaderField'; import { BoolCast, Cast, DateCast, DocCast, FieldValue, StrCast, toList } from '../../../../fields/Types'; @@ -30,8 +30,11 @@ import { Colors } from '../../global/globalEnums'; import { DocumentView } from '../../nodes/DocumentView'; import { FieldViewProps } from '../../nodes/FieldView'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; -import { FInfotoColType } from './CollectionSchemaView'; +import { CollectionSchemaView, FInfotoColType } from './CollectionSchemaView'; import './CollectionSchemaView.scss'; +import { SchemaColumnHeader } from './SchemaColumnHeader'; +import { ContextMenu } from '../../ContextMenu'; +import { SchemaCellField } from './SchemaCellField'; export interface SchemaTableCellProps { Document: Doc; @@ -48,7 +51,6 @@ export interface SchemaTableCellProps { isRowActive: () => boolean | undefined; getFinfo: (fieldKey: string) => FInfo | undefined; setColumnValues: (field: string, value: string) => boolean; - setSelectedColumnValues: (field: string, value: string) => boolean; oneLine?: boolean; // whether all input should fit on one line vs allowing textare multiline inputs allowCRs?: boolean; // allow carriage returns in text input (othewrise CR ends the edit) finishEdit?: () => void; // notify container that edit is over (eg. to hide view in DashFieldView) @@ -57,23 +59,45 @@ export interface SchemaTableCellProps { transform: () => Transform; autoFocus?: boolean; // whether to set focus on creation, othwerise wait for a click rootSelected?: () => boolean; + rowSelected: () => boolean; + isolatedSelection: (doc: Doc) => [boolean, boolean]; + highlightCells: (text: string) => void; + equationHighlightRef: ObservableMap<HTMLDivElement, string>; + eqHighlightFunc: (text: string) => HTMLDivElement[] | []; + refSelectModeInfo: {enabled: boolean, currEditing: SchemaCellField | undefined}; + selectReference: (doc: Doc, col: number) => void; } function selectedCell(props: SchemaTableCellProps) { return ( props.isRowActive() && - props.selectedCol() === props.col && // + props.selectedCol() === props.col && props.selectedCells()?.filter(d => d === props.Document)?.length ); } @observer export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellProps> { + + // private _fieldRef: SchemaCellField | null = null; + private _submittedValue: string = ''; + constructor(props: SchemaTableCellProps) { super(props); makeObservable(this); } + get docIndex(){return DocumentView.getDocViewIndex(this._props.Document);} // prettier-ignore + + get isDefault(){return SchemaColumnHeader.isDefaultField(this._props.fieldKey);} // prettier-ignore + + get lockedInteraction(){return (this.isDefault || this._props.Document._lockedSchemaEditing);} // prettier-ignore + + get backgroundColor(){ + if (this.lockedInteraction) {return '#F5F5F5'} + return '' + } + static addFieldDoc = (docs: Doc | Doc[] /* , where: OpenWhere */) => { DocumentView.FocusOrOpen(toList(docs)[0]); return true; @@ -83,15 +107,12 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro let protoCount = 0; let doc: Doc | undefined = Document; while (doc) { - if (Object.keys(doc).includes(fieldKey.replace(/^_/, ''))) { - break; - } + if (Object.keys(doc).includes(fieldKey.replace(/^_/, ''))) break; protoCount++; doc = DocCast(doc.proto); } - const parenCount = Math.max(0, protoCount - 1); const color = protoCount === 0 || (fieldKey.startsWith('_') && Document[fieldKey] === undefined) ? 'black' : 'blue'; // color of text in cells - const textDecoration = color !== 'black' && parenCount ? 'underline' : ''; + const textDecoration = ''; const fieldProps: FieldViewProps = { childFilters: returnEmptyFilter, childFiltersByRanges: returnEmptyFilter, @@ -122,33 +143,75 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro return { color, textDecoration, fieldProps, cursor, pointerEvents }; } + // @action + // appendTextToField = (text: string) => { + // this._fieldRef?.appendText(text); + // } + + adjustSelfReference = (field: string) => { + const modField = field.replace(/\bthis.\b/g, `d${this.docIndex}.`); + return modField; + } + + // parses a field from the "idToDoc(####)" format to DocumentId (d#) format for readability + cleanupField = (field: string) => { + let modField = field.slice(); + let eqSymbol: string = ''; + if (modField.startsWith('=')) {modField = modField.substring(1); eqSymbol += '=';} + if (modField.startsWith(':=')) {modField = modField.substring(2); eqSymbol += ':=';} + + const idPattern = /idToDoc\((.*?)\)/g; + let matches; + let results = new Array<[id: string, func: string]>(); + while ((matches = idPattern.exec(field)) !== null) {results.push([matches[0], matches[1].replace(/"/g, '')]); } + results.forEach((idFuncPair) => {modField = modField.replace(idFuncPair[0], 'd' + (DocumentView.getDocViewIndex(IdToDoc(idFuncPair[1]))).toString());}) + + for (let i = 0; i < modField.length; ++i){ + + } + + if (modField.endsWith(';')) modField = modField.substring(0, modField.length - 1); + + const inQuotes = (field: string) => {return ((field.startsWith('`') && field.endsWith('`')) || (field.startsWith("'") && field.endsWith("'")) || (field.startsWith('"') && field.endsWith('"')))} + if (!inQuotes(this._submittedValue) && inQuotes(modField)) modField = modField.substring(1, modField.length - 1); + + return eqSymbol + modField; + } + @computed get defaultCellContent() { const { color, textDecoration, fieldProps, pointerEvents } = SchemaTableCell.renderProps(this._props); return ( <div className="schemacell-edit-wrapper" + // onContextMenu={} style={{ color, textDecoration, width: '100%', - pointerEvents, + pointerEvents: this.lockedInteraction ? 'none' : pointerEvents, }}> - <EditableView + <SchemaCellField + fieldKey={this._props.fieldKey} + refSelectModeInfo={this._props.refSelectModeInfo} + Document={this._props.Document} + highlightCells={(text: string) => this._props.highlightCells(this.adjustSelfReference(text))} + getCells={(text: string) => this._props.eqHighlightFunc(this.adjustSelfReference(text))} ref={r => selectedCell(this._props) && this._props.autoFocus && r?.setIsFocused(true)} oneLine={this._props.oneLine} - allowCRs={this._props.allowCRs} contents={undefined} fieldContents={fieldProps} editing={selectedCell(this._props) ? undefined : false} - GetValue={() => Field.toKeyValueString(fieldProps.Document, this._props.fieldKey, SnappingManager.MetaKey)} + GetValue={() => this.cleanupField(Field.toKeyValueString(fieldProps.Document, this._props.fieldKey, SnappingManager.MetaKey))} SetValue={undoable((value: string, shiftDown?: boolean, enterKey?: boolean) => { if (shiftDown && enterKey) { this._props.setColumnValues(this._props.fieldKey.replace(/^_/, ''), value); this._props.finishEdit?.(); return true; } - const ret = Doc.SetField(fieldProps.Document, this._props.fieldKey.replace(/^_/, ''), value, Doc.IsDataProto(fieldProps.Document) ? true : undefined); + const hasNoLayout = Doc.IsDataProto(fieldProps.Document) ? true : undefined; // the "delegate" is a a data document so never write to it's proto + const ret = Doc.SetField(fieldProps.Document, this._props.fieldKey.replace(/^_/, ''), value, hasNoLayout); + this._submittedValue = value; this._props.finishEdit?.(); return ret; }, 'edit schema cell')} @@ -184,23 +247,48 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro } } + @computed get borderColor() { + const sides: Array<string | undefined> = []; + sides[0] = selectedCell(this._props) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // left + sides[1] = selectedCell(this._props) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // right + sides[2] = (!this._props.isolatedSelection(this._props.Document)[0] && selectedCell(this._props)) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // top + sides[3] = (!this._props.isolatedSelection(this._props.Document)[1] && selectedCell(this._props)) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // bottom + return sides; + } + render() { return ( <div className="schema-table-cell" onContextMenu={e => StopEvent(e)} onPointerDown={action(e => { + if (this.lockedInteraction) { e.stopPropagation(); e.preventDefault(); return; } + + if (this._props.refSelectModeInfo.enabled && !selectedCell(this._props)){ + e.stopPropagation(); + e.preventDefault(); + this._props.selectReference(this._props.Document, this._props.col); + return; + } + const shift: boolean = e.shiftKey; const ctrl: boolean = e.ctrlKey; - if (this._props.isRowActive?.() !== false) { + if (this._props.isRowActive?.()) { if (selectedCell(this._props) && ctrl) { this._props.selectCell(this._props.Document, this._props.col, shift, ctrl); e.stopPropagation(); } else !selectedCell(this._props) && this._props.selectCell(this._props.Document, this._props.col, shift, ctrl); } })} - style={{ padding: this._props.padding, maxWidth: this._props.maxWidth?.(), width: this._props.columnWidth() || undefined, border: selectedCell(this._props) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined }}> - {this.content} + style={{ padding: this._props.padding, + maxWidth: this._props.maxWidth?.(), + width: this._props.columnWidth() || undefined, + borderLeft: this.borderColor[0], + borderRight: this.borderColor[1], + borderTop: this.borderColor[2], + borderBottom: this.borderColor[3], + backgroundColor: this.backgroundColor}}> + {this.isDefault ? '' : this.content} </div> ); } @@ -301,7 +389,7 @@ export class SchemaDateCell extends ObservableReactComponent<SchemaTableCellProp const { pointerEvents } = SchemaTableCell.renderProps(this._props); return ( <> - <div style={{ pointerEvents: 'none' }}> + <div style={{ pointerEvents: 'none'}}> <DatePicker dateFormat="Pp" selected={this.date?.date ?? Date.now()} onChange={emptyFunction} /> </div> {pointerEvents === 'none' ? null : ( @@ -441,4 +529,4 @@ export class SchemaEnumerationCell extends ObservableReactComponent<SchemaTableC </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts index 2b7de5082..6f9afe165 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -81,6 +81,8 @@ ScriptingGlobals.add(function setBackgroundColor(color?: string, checkResult?: b } else { const dataKey = Doc.LayoutFieldKey(dv.Document); const alternate = (dv.layoutDoc[dataKey + '_usePath'] ? '_' + dv.layoutDoc[dataKey + '_usePath'] : '').replace(':hover', ''); + console.log('color: ' + dv.dataDoc[fieldKey + alternate] + ' to set to: ' + color) + dv.layoutDoc[fieldKey + alternate] = undefined; dv.dataDoc[fieldKey + alternate] = color; } }); diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 4d5f15a3e..e943dd2e3 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -5,14 +5,14 @@ import { Colors, Toggle, ToggleType, Type } from 'browndash-components'; import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { returnEmptyString, returnFalse, returnOne, setupMoveUpEvents } from '../../../../ClientUtils'; +import { ClientUtils, returnEmptyString, returnFalse, returnOne, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; -import { Doc, DocListCast, Field, Opt, StrListCast } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, FieldType, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; import { InkTool } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; import { Cast, CsvCast, DocCast, NumCast, StrCast } from '../../../../fields/Types'; import { CsvField } from '../../../../fields/URLField'; -import { TraceMobx } from '../../../../fields/util'; +import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util'; import { DocUtils } from '../../../documents/DocUtils'; import { DocumentType } from '../../../documents/DocumentTypes'; import { Docs } from '../../../documents/Documents'; @@ -32,6 +32,15 @@ import { Histogram } from './components/Histogram'; import { LineChart } from './components/LineChart'; import { PieChart } from './components/PieChart'; import { TableBox } from './components/TableBox'; +import { LinkManager } from '../../../util/LinkManager'; +import { DataVizTemplateInfo, DataVizTemplateLayout, DocCreatorMenu, LayoutType } from './DocCreatorMenu'; +import { CollectionFreeFormView } from '../../collections/collectionFreeForm'; +import { PrefetchProxy } from '../../../../fields/Proxy'; +import { AclAdmin, AclAugment, AclEdit } from '../../../../fields/DocSymbols'; +import { template } from 'lodash'; +import { data } from 'jquery'; +import { listSpec } from '../../../../fields/Schema'; +import { ObjectField } from '../../../../fields/ObjectField'; export enum DataVizView { TABLE = 'table', @@ -51,6 +60,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { crop: ((region: Doc | undefined, addCrop?: boolean) => Doc | undefined) | undefined; @observable _marqueeing: number[] | undefined = undefined; @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + @observable _specialHighlightedRow: number | undefined = undefined; constructor(props: FieldViewProps) { super(props); @@ -125,6 +135,10 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this.layoutDoc._dataViz_titleCol = titleCol; }; + @action setSpecialHighlightedRow = (row: number | undefined) => { + this._specialHighlightedRow = row; + } + @action // pinned / linked anchor doc includes selected rows, graph titles, and graph colors restoreView = (data: Doc) => { // const changedView = data.config_dataViz && this.dataVizView !== data.config_dataViz && (this.layoutDoc._dataViz = data.config_dataViz); @@ -146,6 +160,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // } // return func() ?? false; }; + getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = AnchorMenu.Instance.GetAnchor?.(undefined, addAsAnnotation); const anchor = !pinProps @@ -359,7 +374,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; if (!this.records.length) return 'no data/visualization'; switch (this.dataVizView) { - case DataVizView.TABLE: return <TableBox {...sharedProps} docView={this.DocumentView} selectAxes={this.selectAxes} selectTitleCol={this.selectTitleCol}/>; + case DataVizView.TABLE: return <TableBox {...sharedProps} specHighlightedRow={this._specialHighlightedRow} docView={this.DocumentView} selectAxes={this.selectAxes} selectTitleCol={this.selectTitleCol}/>; case DataVizView.LINECHART: return <LineChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} vizBox={this} />; case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} />; case DataVizView.PIECHART: return <PieChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} @@ -426,11 +441,18 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this.layoutDoc.dataViz_filterSelection = !this.layoutDoc.dataViz_filterSelection; }; - specificContextMenu = (): void => { + openDocCreatorMenu = (x: number, y: number) => { + DocCreatorMenu.Instance.toggleDisplay(x, y); + DocCreatorMenu.Instance.setDataViz(this); + DocCreatorMenu.Instance.setTemplateDocs(this.getPossibleTemplates()); + } + + specificContextMenu = (x: number, y: number): void => { const cm = ContextMenu.Instance; const options = cm.findByDescription('Options...'); const optionItems = options && 'subitems' in options ? options.subitems : []; optionItems.push({ description: `Analyze with AI`, event: () => this.askGPT(), icon: 'lightbulb' }); + optionItems.push({ description: `Create documents`, event: () => this.openDocCreatorMenu(x, y), icon: 'table-cells' }); !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); }; @@ -445,6 +467,105 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { GPTPopup.Instance.generateDataAnalysis(); }); + getPossibleTemplates = (): Doc[] => { + const linkedDocs: Doc[] = LinkManager.Instance.getAllRelatedLinks(this.Document).map(d => DocCast(LinkManager.getOppositeAnchor(d, this.Document))); + const linkedCollections: Doc[] = linkedDocs.filter(doc => doc.type === 'config').map(doc => DocCast(doc.annotationOn)); + const isColumnTitle = (title: string): boolean => { + const colTitles: string[] = Object.keys(this.records[0]); + for (let i = 0; i < colTitles.length; ++i){ + if (colTitles[i] === title) { + console.log(true); + return true; + } + } + return false; + } + const isValidTemplate = (collection: Doc) => { + const childDocs = DocListCast(collection[Doc.LayoutFieldKey(collection)]); + for (let i = 0; i < childDocs.length; ++i){ + if (isColumnTitle(String(childDocs[i].title))) return true; + } + return false; + } + return linkedCollections.filter(col => isValidTemplate(col)); + } + + ApplyTemplateTo = (templateDoc: Doc, target: Doc, targetKey: string, titleTarget: string | undefined) => { + if (!Doc.AreProtosEqual(target[targetKey] as Doc, templateDoc)) { + if (target.resolvedDataDoc) { + target[targetKey] = new PrefetchProxy(templateDoc); + } else { + titleTarget && (Doc.GetProto(target).title = titleTarget); + const setDoc = [AclAdmin, AclEdit, AclAugment].includes(GetEffectiveAcl(Doc.GetProto(target))) ? Doc.GetProto(target) : target; + setDoc[targetKey] = new PrefetchProxy(templateDoc); + } + } + return target; + } + + applyLayout = (templateInfo: DataVizTemplateInfo, docs: Doc[]) => { + if (templateInfo.layout.type === LayoutType.Stacked) return; + const columns: number = templateInfo.columns; + const xGap: number = templateInfo.layout.xMargin; + const yGap: number = templateInfo.layout.yMargin; + const repeat: number = templateInfo.layout.repeat; + const startX: number = templateInfo.referencePos.x; + const startY: number = templateInfo.referencePos.y; + const templWidth = Number(templateInfo.doc._width); + const templHeight = Number(templateInfo.doc._height); + + let i: number = 0; + let docsChanged: number = 0; + let curX: number = startX; + let curY: number = startY; + + while (docsChanged < docs.length){ + while (i < columns && docsChanged < docs.length){ + docs[docsChanged].x = curX; + docs[docsChanged].y = curY; + curX += templWidth + xGap; + ++docsChanged; + ++i; + } + + i = 0; + curX = startX; + curY += templHeight + yGap; + } + } + + // @action addSavedLayout = (layout: DataVizTemplateLayout) => { + // const saved = Cast(this.layoutDoc.dataViz_savedTemplates, listSpec('RefField')); + + // } + + @action + createDocsFromTemplate = (templateInfo: DataVizTemplateInfo) => { + if (!templateInfo.doc) return; + const mainCollection = this.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; + const fields: string[] = Array.from(Object.keys(this.records[0])); + const selectedRows = NumListCast(this.layoutDoc.dataViz_selectedRows); + const docs: Doc[] = selectedRows.map(row => { + const values: String[] = []; + fields.forEach(col => values.push(this.records[row][col])); + + const proto = new Doc(); + proto.author = ClientUtils.CurrentUserEmail(); + values.forEach((val, i) => {proto[fields[i]] = val as FieldType}); + + const target = Doc.MakeDelegate(proto); + const targetKey = StrCast(templateInfo.doc!.layout_fieldKey, 'layout'); + const applied = this.ApplyTemplateTo(templateInfo.doc!, target, targetKey, templateInfo.doc!.title + `${row}`); + target.layout_fieldKey = targetKey; + //this.applyImagesTo(target, fields); + return applied; + }); + + docs.forEach(doc => mainCollection.addDocument(doc)); + + this.applyLayout(templateInfo, docs); + } + /** * creates a new dataviz document filter from this one * it appears to the right of this document, with the @@ -498,7 +619,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { transform: `scale(${scale})`, position: 'absolute', }} - onContextMenu={this.specificContextMenu} + onContextMenu={(e) => this.specificContextMenu(e.pageX, e.pageY)} onWheel={e => e.stopPropagation()} ref={this._mainCont}> <div className="datatype-button"> diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu.scss b/src/client/views/nodes/DataVizBox/DocCreatorMenu.scss new file mode 100644 index 000000000..ac60ddb3e --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu.scss @@ -0,0 +1,564 @@ +.no-margin { + margin-top: 0px !important; + margin-bottom: 0px !important; + margin-left: 0px !important; + margin-right: 0px !important; +} + +.docCreatorMenu-cont { + position: absolute; + z-index: 100000; + // box-shadow: 0px 3px 4px rgba(0, 0, 0, 30%); + // background: whitesmoke; + // color: black; + border-radius: 3px; +} + +.docCreatorMenu-menu { + display: flex; + flex-direction: row; + height: 25px; + align-items: flex-end; +} + +.docCreatorMenu-menu-button { + width: 30px; + height: 30px; + background: whitesmoke; + background-color: rgb(34, 34, 37); + border-radius: 5px; + border: 1px solid rgb(180, 180, 180); + padding: 0px; + font-size: 14px; + //box-shadow: 3px 3px rgb(29, 29, 31); + + &:hover { + box-shadow: none; + } + + &.right{ + margin-left: 0px; + font-size: 12px; + } + + &.close-menu { + font-size: 12px; + width: 18px; + height: 18px; + border-radius: 2px; + font-size: 12px; + margin-left: auto; + } + + &.options { + margin-left: 0px; + } + + &:hover { + background-color: rgb(60, 60, 65); + } + + &.top-bar { + border-bottom: 25px solid #555; + border-left: 12px solid transparent; + border-right: 12px solid transparent; + // border-top-left-radius: 5px; + // border-top-right-radius: 5px; + border-radius: 0px; + height: 0; + width: 50px; + } + + &.preview-toggle { + margin: 0px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-left: 0px; + } +} + +.docCreatorMenu-top-buttons-container { + position: relative; + margin-top: 5px; + margin-left: 7px; + display: flex; + flex-direction: row; + align-items: flex-end; + width: 150px; + height: auto; +} + +.top-button-container { + position: relative; + width: 52px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + + &.left { + z-index: 3; + } + + &.middle { + position: absolute; + left: 40px; + z-index: 2; + + &.selected { + z-index: 4; + } + } + + &.right { + position: absolute; + left: 80px; + z-index: 1; + + &.selected { + z-index: 4; + } + } + + &:hover::before{ + border-bottom: 20px solid rgb(82, 82, 82); + } + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + border-bottom: 20px solid rgb(50, 50, 50); + border-left: 12px solid transparent; + border-right: 12px solid transparent; + height: 0; + width: 50px; + } + + &::after { + content: ""; + position: absolute; + top: -1px; + left: -1px; + border-bottom: 22px solid rgb(180, 180, 180); + border-left: 12px solid transparent; + border-right: 12px solid transparent; + height: 0; + width: 52px; + z-index: -1; + } + + &.selected::before { + border-bottom-color: rgb(67, 119, 214); + } +} + +.top-button-content { + position: relative; + z-index: 1; + color: white; +} + +.docCreatorMenu-menu-hr{ + margin-top: 0px; + margin-bottom: 0px; + color: rgb(180, 180, 180); +} + +.docCreatorMenu-placement-indicator { + position: absolute; + z-index: 100000; + border-left: solid 3px #9fd7fb; + border-top: solid 3px #9fd7fb; + width: 25px; + height: 25px; +} + +.docCreatorMenu-general-options-container { + display: flex; + justify-content: center; + align-items: center; + margin: 0px; + padding: 0px; + gap: 5px; +} + +.docCreatorMenu-save-layout-button { + display: flex; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + background-color: rgb(99, 148, 238); + border: 2px solid rgb(80, 107, 152); + border-radius: 5px; + margin-bottom: 20px; + font-size: 25px; + + &:hover{ + background-color: rgb(59, 128, 255); + border: 2px solid rgb(53, 80, 127); + } +} + +.docCreatorMenu-create-docs-button { + width: 40px; + height: 40px; + background-color: rgb(176, 229, 149); + border: 2px solid rgb(126, 219, 80); + border-radius: 5px; + padding: 0px; + font-size: 25px; + color: white; + flex: 0 0 auto; + margin-bottom: 20px; //remove later !!! + + &:hover { + background-color: rgb(129, 223, 83); + border: 2px solid rgb(80, 185, 28); + } +} + +.docCreatorMenu-option-divider { + border-top: 1px solid rgb(180, 180, 180); + width: 225px; + margin-top: 10px; + margin-bottom: 10px; +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// Resizers CSS +//-------------------------------------------------------------------------------------------------------------------------------------------- + +.docCreatorMenu-resizer { + position: absolute; + background-color: none; + + &.top, &.bottom { + height: 10px; + cursor: ns-resize; + } + + &.right, &.left { + width: 10px; + cursor: ew-resize; + } + + &.topRight, &.topLeft, &.bottomRight, &.bottomLeft { + height: 15px; + width: 15px; + background-color: none; + } +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// DocCreatorMenu templates preview CSS +//-------------------------------------------------------------------------------------------------------------------------------------------- + + +.docCreatorMenu-preview-container { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: 140px; + grid-auto-rows: 141px; + overflow-y: scroll; + margin: 5px; + margin-top: 0px; + width: calc(100% - 10px); + height: calc(100% - 30px); + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; +} + +.docCreatorMenu-preview-window { + display: flex; + justify-content: center; + align-items: center; + width: 125px; + height: 125px; + margin-top: 10px; + margin-left: 10px; + border: 1px solid rgb(163, 163, 163); + border-radius: 5px; + box-shadow: 5px 5px rgb(29, 29, 31); + + &:hover{ + background-color: rgb(72, 72, 73); + } + + .docCreatorMenu-preview-image{ + width: 105px; + height: 105px; + border-radius: 5px; + } + + &.empty { + font-size: 35px; + } +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// DocCreatorMenu options CSS +//-------------------------------------------------------------------------------------------------------------------------------------------- + +.docCreatorMenu-option-container{ + display: flex; + width: 180px; + height: 30px; + flex-direction: row; + justify-content: center; + align-items: center; + margin-top: 10px; + margin-bottom: 10px; + + &.layout{ + z-index: 5; + } +} + +.docCreatorMenu-option-title{ + display: flex; + width: 140px; + height: 30px; + background: whitesmoke; + background-color: rgb(34, 34, 37); + border-radius: 5px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border: 1px solid rgb(180, 180, 180); + padding: 0px; + font-size: 12px; + align-items: center; + justify-content: center; + text-transform: uppercase; + cursor: pointer; + + &.spacer { + border-left: none; + border-right: none; + border-radius: 0px; + width: auto; + text-transform: none; + + &.small { + height: 20px; + transform: translateY(-5px); + } + } + + &.config { + border-radius: 4px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + width: 30px; + border-right: 0px; + gap: 3px; + + &.layout-config { + height: 20px; + transform: translateY(-5px); + text-transform: none; + padding-left: 2px; + } + + &.dimensions { + text-transform: none; + height: 20px; + transform: translateY(-5px); + width: 70px; + } + } +} + +.docCreatorMenu-input { + display: flex; + height: 30px; + background-color: rgb(34, 34, 37); + border: 1px solid rgb(180, 180, 180); + align-items: center; + justify-content: center; + + &.config { + border-radius: 4px; + margin: 0px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-left: 0px; + width: 25px; + + &.layout-config { + height: 20px; + transform: translateY(-5px); + } + + &.dimensions { + height: 20px; + width: 30px; + transform: translateY(-5px); + + &.right { + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + } + + &.left { + border-radius: 0px; + border-right: 0px; + } + } + } +} + +.docCreatorMenu-configuration-bar { + width: 200; + gap: 5px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + &.no-gap { + gap: 0px; + } +} + +.docCreatorMenu-menu-container { + display: flex; + flex-direction: column; + align-items: center; + overflow-y: scroll; + margin: 5px; + margin-top: 0px; + width: calc(100% - 10px); + height: calc(100% - 30px); + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; + + .docCreatorMenu-option-container{ + width: 180px; + height: 30px; + + .docCreatorMenu-dropdown-hoverable { + width: 140px; + height: 30px; + + &:hover .docCreatorMenu-dropdown-content { + display: block; + } + + &:hover .docCreatorMenu-option-title { + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + } + + .docCreatorMenu-dropdown-content { + display: none; + min-width: 100px; + height: 75px; + overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; + border-bottom: 1px solid rgb(180, 180, 180); + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + + .docCreatorMenu-dropdown-option{ + display: flex; + background-color: rgb(42, 42, 46); + border-left: 1px solid rgb(180, 180, 180); + border-right: 1px solid rgb(180, 180, 180); + border-bottom: 1px solid rgb(180, 180, 180); + width: 140px; + height: 25px; + justify-content: center; + justify-items: center; + padding-top: 3px; + + &:hover { + background-color: rgb(68, 68, 74); + cursor: pointer; + } + } + } + } + + } +} + +.docCreatorMenu-layout-preview-window-wrapper { + display: flex; + justify-content: center; + align-items: center; + width: 85%; + height: auto; + position: relative; + padding: 0px; + + &:hover .docCreatorMenu-zoom-button-container { + display: block; + } + + .docCreatorMenu-layout-preview-window { + padding: 5px; + flex: 0 0 auto; + overflow: scroll; + display: grid; + width: 100%; + aspect-ratio: 1.23; + //height: auto; + // max-width: 240; + // max-height: 240; + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; + background-color: rgb(34, 34, 37); + -ms-overflow-style: none; + scrollbar-width: none; + + &.small { + max-width: 100; + max-height: 100; + } + + .docCreatorMenu-layout-preview-item { + display: flex; + justify-content: center; + align-items: center; + border-radius: 3px; + border: solid 1px lightblue; + + &:hover { + border: solid 2px rgb(68, 153, 233); + z-index: 2; + } + } + } + + .docCreatorMenu-zoom-button-container { + position: absolute; + top: 0px; + display: flex; + justify-content: center; + align-items: center; + display: none; + z-index: 999; + } + + .docCreatorMenu-zoom-button{ + width: 15px; + height: 15px; + background: whitesmoke; + background-color: rgb(34, 34, 37); + border-radius: 3px; + border: 1px solid rgb(180, 180, 180); + padding: 0px; + font-size: 10px; + z-index: 6; + margin-left: 0px; + margin-top: 0px; + margin-right: 0px; //225px + margin-bottom: 0px; + } +} + + diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx new file mode 100644 index 000000000..ef53dde8e --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx @@ -0,0 +1,694 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { returnAll, returnFalse, setupMoveUpEvents } from '../../../../ClientUtils'; +import { Doc, NumListCast } from '../../../../fields/Doc'; +import { DocCast, ImageCast } from '../../../../fields/Types'; +import { ImageField } from '../../../../fields/URLField'; +import { emptyFunction } from '../../../../Utils'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { UndoManager, undoable } from '../../../util/UndoManager'; +import { ObservableReactComponent } from '../../ObservableReactComponent'; +import { DocumentView } from '../DocumentView'; +import { DataVizBox } from './DataVizBox'; +import './DocCreatorMenu.scss'; +import { Id } from '../../../../fields/FieldSymbols'; +import { Colors, IconButton, Size } from 'browndash-components'; +import { MakeTemplate } from '../../../util/DropConverter'; +import { threadId } from 'worker_threads'; +import { ideahub } from 'googleapis/build/src/apis/ideahub'; +import { DragManager } from '../../../util/DragManager'; +import { DateField } from '../../../../fields/DateField'; + +export enum LayoutType { + Stacked = 'stacked', + Grid = 'grid', + Row = 'row', + Column = 'column', + Custom = 'custom' +} + +@observer +export class DocCreatorMenu extends ObservableReactComponent<{}> { + + static Instance: DocCreatorMenu; + + private _ref: HTMLDivElement | null = null; + + @observable _templateDocs: Doc[] = []; + @observable _selectedTemplate: Doc | undefined = undefined; + + @observable _layout: {type: LayoutType, yMargin: number, xMargin: number, columns?: number, repeat: number} = {type: LayoutType.Grid, yMargin: 0, xMargin: 0, repeat: 0}; + @observable _layoutPreview: boolean = true; + @observable _layoutPreviewScale: number = 1; + @observable _savedLayouts: DataVizTemplateLayout[] = []; + + @observable _pageX: number = 0; + @observable _pageY: number = 0; + @observable _indicatorX: number | undefined = undefined; + @observable _indicatorY: number | undefined = undefined; + @observable _display: boolean = false; + + @observable _hoveredLayoutPreview: number | undefined = undefined; + @observable _mouseX: number = -1; + @observable _mouseY: number = -1; + @observable _startPos?: {x: number, y: number}; + @observable _shouldDisplay: boolean = false; + + @observable _menuContent: 'templates' | 'options' | 'saved' = 'templates'; + @observable _dragging: boolean = false; + @observable _draggingIndicator: boolean = false; + @observable _dataViz?: DataVizBox; + @observable _interactionLock: any; + @observable _snapPt: any; + @observable _resizeHdlId: string = ''; + @observable _resizing: boolean = false; + @observable _offset: {x: number, y: number} = {x: 0, y: 0}; + @observable _resizeUndo: UndoManager.Batch | undefined = undefined; + @observable _initDimensions: {width: number, height: number, x?: number, y?: number} = {width: 300, height: 400, x: undefined, y: undefined}; + @observable _menuDimensions: {width: number, height: number} = {width: 300, height: 400}; + + constructor(props: any) { + super(props); + makeObservable(this); + DocCreatorMenu.Instance = this; + } + + @action setDataViz = (dataViz: DataVizBox) => { this._dataViz = dataViz }; + @action setTemplateDocs = (docs: Doc[]) => {this._templateDocs = docs.map(doc => doc.annotationOn ? DocCast(doc.annotationOn):doc)}; + + @computed get docsToRender() { + return this._selectedTemplate ? NumListCast(this._dataViz?.layoutDoc.dataViz_selectedRows) : []; + } + + @computed get rowsCount(){ + switch (this._layout.type) { + case LayoutType.Row: case LayoutType.Stacked: + return 1; + case LayoutType.Column: + return this.docsToRender.length; + case LayoutType.Grid: + return Math.ceil(this.docsToRender.length / (this._layout.columns ?? 1)) ?? 0; + default: + return 0; + } + } + + @computed get columnsCount(){ + switch (this._layout.type) { + case LayoutType.Row: + return this.docsToRender.length; + case LayoutType.Column: case LayoutType.Stacked: + return 1; + case LayoutType.Grid: + return this._layout.columns ?? 0; + default: + return 0; + } + } + + @computed get canMakeDocs(){ + return this._selectedTemplate !== undefined && this._layout !== undefined; + } + + get bounds(): {t: number, b: number, l: number, r: number} { + const rect = this._ref?.getBoundingClientRect(); + const bounds = {t: rect?.top ?? 0, b: rect?.bottom ?? 0, l: rect?.left ?? 0, r: rect?.right ?? 0}; + return bounds; + } + + setUpButtonClick = (e: any, func: Function) => { + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + func(); + }, 'create docs') + ) + } + + @action + onPointerDown = (e: PointerEvent) => { + this._mouseX = e.clientX; + this._mouseY = e.clientY; + }; + + @action + onPointerUp = (e: PointerEvent) => { + if (this._resizing) { + this._initDimensions.width = this._menuDimensions.width; + this._initDimensions.height = this._menuDimensions.height; + this._initDimensions.x = this._pageX; + this._initDimensions.y = this._pageY; + document.removeEventListener('pointermove', this.onResize); + SnappingManager.SetIsResizing(undefined); + this._resizing = false; + } + if (this._dragging) { + document.removeEventListener('pointermove', this.onDrag); + this._dragging = false; + } + if (e.button !== 2 && !e.ctrlKey) return; + const curX = e.clientX; + const curY = e.clientY; + if (Math.abs(this._mouseX - curX) > 1 || Math.abs(this._mouseY - curY) > 1) { + this._shouldDisplay = false; + } + }; + + _disposer: IReactionDisposer | undefined; + componentDidMount() { + document.addEventListener('pointerdown', this.onPointerDown, true); + document.addEventListener('pointerup', this.onPointerUp); + this._disposer = reaction(() => this._templateDocs.slice(), (docs) => docs.map(this.getIcon)); + } + + componentWillUnmount() { + this._disposer?.(); + document.removeEventListener('pointerdown', this.onPointerDown, true); + document.removeEventListener('pointerup', this.onPointerUp); + } + + @action + toggleDisplay = (x: number, y: number) => { + if (this._shouldDisplay) { + this._shouldDisplay = false; + } else { + this._pageX = x; + this._pageY = y; + this._shouldDisplay = true; + } + }; + + @action + closeMenu = () => { + const wasOpen = this._display; + this._display = false; + this._shouldDisplay = false; + return wasOpen; + }; + + @action + onResizePointerDown = (e: React.PointerEvent): void => { + this._resizing = true; + document.addEventListener('pointermove', this.onResize); + SnappingManager.SetIsResizing(DocumentView.Selected().lastElement()?.Document[Id]); // turns off pointer events on things like youtube videos and web pages so that dragging doesn't get "stuck" when cursor moves over them + e.stopPropagation(); + const id = (this._resizeHdlId = e.currentTarget.className); + const pad = id.includes('Left') || id.includes('Right') ? Number(getComputedStyle(e.target as any).width.replace('px', '')) / 2 : 0; + const bounds = e.currentTarget.getBoundingClientRect(); + this._offset = { + x: id.toLowerCase().includes('left') ? bounds.right - e.clientX - pad : bounds.left - e.clientX + pad, // + y: id.toLowerCase().includes('top') ? bounds.bottom - e.clientY - pad : bounds.top - e.clientY + pad, + }; + this._resizeUndo = UndoManager.StartBatch('drag resizing'); + this._snapPt = { x: e.pageX, y: e.pageY }; + }; + + @action + onResize = (e: any): boolean => { + const dragHdl = this._resizeHdlId.split(' ')[1]; + const thisPt = DragManager.snapDrag(e, -this._offset.x, -this._offset.y, this._offset.x, this._offset.y); + + const { scale, refPt, transl } = this.getResizeVals(thisPt, dragHdl); + !this._interactionLock && runInAction(async () => { // resize selected docs if we're not in the middle of a resize (ie, throttle input events to frame rate) + this._interactionLock = true; + const scaleAspect = {x: scale.x, y: scale.y}; + this.resizeView(refPt, scaleAspect, transl); // prettier-ignore + await new Promise<any>(res => { setTimeout(() => { res(this._interactionLock = undefined)})}); + }); // prettier-ignore + return true; + } + + @action + onDrag = (e: any): boolean => { + this._pageX = e.pageX - (this._startPos?.x ?? 0); + this._pageY = e.pageY - (this._startPos?.y ?? 0); + this._initDimensions.x = this._pageX; + this._initDimensions.y = this._pageY; + return true; + } + + getResizeVals = (thisPt: { x: number; y: number }, dragHdl: string) => { + const [w, h] = [this._initDimensions.width, this._initDimensions.height]; + const [moveX, moveY] = [thisPt.x - this._snapPt.x, thisPt.y - this._snapPt.y]; + let vals: {scale: {x: number, y: number}, refPt: [number, number], transl: {x: number, y: number}}; + switch (dragHdl) { + case 'topLeft': vals = { scale: { x: 1 - moveX / w, y: 1 -moveY / h }, refPt: [this.bounds.r, this.bounds.b], transl: {x: moveX, y: moveY } }; break; + case 'topRight': vals = { scale: { x: 1 + moveX / w, y: 1 -moveY / h }, refPt: [this.bounds.l, this.bounds.b], transl: {x: 0, y: moveY } }; break; + case 'top': vals = { scale: { x: 1, y: 1 -moveY / h }, refPt: [this.bounds.l, this.bounds.b], transl: {x: 0, y: moveY } }; break; + case 'left': vals = { scale: { x: 1 - moveX / w, y: 1 }, refPt: [this.bounds.r, this.bounds.t], transl: {x: moveX, y: 0 } }; break; + case 'bottomLeft': vals = { scale: { x: 1 - moveX / w, y: 1 + moveY / h }, refPt: [this.bounds.r, this.bounds.t], transl: {x: moveX, y: 0 } }; break; + case 'right': vals = { scale: { x: 1 + moveX / w, y: 1 }, refPt: [this.bounds.l, this.bounds.t], transl: {x: 0, y: 0 } }; break; + case 'bottomRight':vals = { scale: { x: 1 + moveX / w, y: 1 + moveY / h }, refPt: [this.bounds.l, this.bounds.t], transl: {x: 0, y: 0 } }; break; + case 'bottom': vals = { scale: { x: 1, y: 1 + moveY / h }, refPt: [this.bounds.l, this.bounds.t], transl: {x: 0, y: 0 } }; break; + default: vals = { scale: { x: 1, y: 1 }, refPt: [this.bounds.l, this.bounds.t], transl: {x: 0, y: 0 } }; break; + } // prettier-ignore + return vals; + }; + + resizeView = (refPt: number[], scale: { x: number; y: number }, translation: {x: number, y: number}) => { + const refCent = [refPt[0], refPt[1]] // fixed reference point for resize (ie, a point that doesn't move) + if (this._initDimensions.x === undefined) this._initDimensions.x = this._pageX; + if (this._initDimensions.y === undefined) this._initDimensions.y = this._pageY; + const {height, width, x, y} = this._initDimensions; + + this._menuDimensions.width = Math.max(100, scale.x * width); + this._menuDimensions.height = Math.max(100, scale.y * height); + this._pageX = x + translation.x; + this._pageY = y + translation.y; + }; + + async getIcon(doc: Doc) { + const docView = DocumentView.getDocumentView(doc); + if (docView) { + docView.ComponentView?.updateIcon?.(); + return new Promise<ImageField | undefined>(res => setTimeout(() => res(ImageCast(docView.Document.icon)), 500)); + } + return undefined; + } + + @action updateSelectedTemplate = (template: Doc) => { + if (this._selectedTemplate === template) { + this._selectedTemplate = undefined; + return; + } else { + this._selectedTemplate = template; + MakeTemplate(template); + } + } + + @action updateSelectedSavedLayout = (layout: DataVizTemplateLayout) => { + this._layout.xMargin = layout.layout.xMargin; + this._layout.yMargin = layout.layout.yMargin; + this._layout.type = layout.layout.type; + this._layout.columns = layout.columns; + } + + isSelectedLayout = (layout: DataVizTemplateLayout) => { + return this._layout.xMargin === layout.layout.xMargin + && this._layout.yMargin === layout.layout.yMargin + && this._layout.type === layout.layout.type + && this._layout.columns === layout.columns; + } + + get templatesPreviewContents(){ + const renderedTemplates: Doc[] = []; + return ( + <div className='docCreatorMenu-preview-container'> + {this._templateDocs.map(doc => ({icon: ImageCast(doc.icon), doc})).filter(info => info.icon && info.doc).map(info => { + if (renderedTemplates.includes(info.doc)) return undefined; + renderedTemplates.push(info.doc); + return (<div + className='docCreatorMenu-preview-window' + style={{ + border: this._selectedTemplate === info.doc ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', + boxShadow: this._selectedTemplate === info.doc ? `0 0 15px rgba(68, 118, 247, .8)` : '' + }} + onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this.updateSelectedTemplate(info.doc)))}> + <img className='docCreatorMenu-preview-image' src={info.icon!.url.href.replace(".png", "_o.png")} /> + </div> + )})} + <div className='docCreatorMenu-preview-window empty'> + <FontAwesomeIcon icon='plus' color='rgb(160, 160, 160)'/> + </div> + </div> + ); + } + + get savedLayoutsPreviewContents(){ + return ( + <div className='docCreatorMenu-preview-container'> + {this._savedLayouts.map((layout, index) => + <div + className='docCreatorMenu-preview-window' + style={{ + border: this.isSelectedLayout(layout) ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', + boxShadow: this.isSelectedLayout(layout) ? `0 0 15px rgba(68, 118, 247, .8)` : '' + }} + onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this.updateSelectedSavedLayout(layout)))} + > + {this.layoutPreviewContents(87, layout, true, index)} + </div> + )} + </div> + ); + } + + @action updateXMargin = (input: string) => { this._layout.xMargin = Number(input) }; + @action updateYMargin = (input: string) => { this._layout.yMargin = Number(input) }; + @action updateColumns = (input: string) => { this._layout.columns = Number(input) }; + + get layoutConfigOptions() { + const optionInput = (icon: string, func: Function, def?: number, key?: string, noMargin?: boolean) => { + return ( + <div className='docCreatorMenu-option-container small no-margin' key={key} style={{marginTop: noMargin ? '0px' : ''}} + > + <div className='docCreatorMenu-option-title config layout-config'> + <FontAwesomeIcon icon={icon as any}/> + </div> + <input defaultValue={def} onInput={(e) => func(e.currentTarget.value)} className='docCreatorMenu-input config layout-config'/> + </div> + ); + } + + switch (this._layout.type) { + case LayoutType.Row: + return ( + <div className='docCreatorMenu-configuration-bar'> + {optionInput('arrows-left-right', this.updateXMargin, this._layout.xMargin, '0')} + </div> + ); + case LayoutType.Column: + return ( + <div className='docCreatorMenu-configuration-bar'> + {optionInput('arrows-up-down', this.updateYMargin, this._layout.yMargin, '1')} + </div> + ); + case LayoutType.Grid: + return ( + <div className='docCreatorMenu-configuration-bar'> + {optionInput('arrows-up-down', this.updateYMargin, this._layout.xMargin, '2')} + {optionInput('arrows-left-right', this.updateXMargin, this._layout.xMargin, '3')} + {optionInput('table-columns', this.updateColumns, this._layout.columns, '4', true)} + </div> + ); + case LayoutType.Stacked: + return null; + default: + break; + } + } + + layoutPreviewContents = (outerSpan: number, altLayout?: DataVizTemplateLayout, small: boolean = false, id?: number) => { + const doc: Doc | undefined = altLayout ? altLayout.template : this._selectedTemplate; + if (!doc) return; + + const layout = altLayout ? altLayout.layout : this._layout; + + const docWidth: number = Number(doc._width); + const docHeight: number = Number(doc._height); + const horizontalSpan: number = (docWidth + layout.xMargin) * (altLayout ? altLayout.columns : this.columnsCount) - layout.xMargin;; + const verticalSpan: number = (docHeight + layout.yMargin) * (altLayout ? altLayout.rows : this.rowsCount) - layout.yMargin; + const largerSpan: number = horizontalSpan > verticalSpan ? horizontalSpan : verticalSpan; + const scaledDown = (input: number) => {return input / (largerSpan / outerSpan * this._layoutPreviewScale)} + const fontSize = Math.min(scaledDown(docWidth / 3), scaledDown(docHeight / 3)); + + return ( + <div className='docCreatorMenu-layout-preview-window-wrapper' id={String(id) ?? undefined}> + <div className='docCreatorMenu-zoom-button-container'> + <button + className='docCreatorMenu-zoom-button' + onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this._layoutPreviewScale *= 1.25))}> + <FontAwesomeIcon icon={'minus'}/> + </button> + <button + className='docCreatorMenu-zoom-button zoom-in' + onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this._layoutPreviewScale *= .75))}> + <FontAwesomeIcon icon={'plus'}/> + </button> + {altLayout ? <button + className='docCreatorMenu-zoom-button trash' + onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this._savedLayouts.splice(this._savedLayouts.indexOf(altLayout), 1)))}> + <FontAwesomeIcon icon={'trash'}/> + </button> : null} + </div> + <div + id={String(id) ?? undefined} + className={`docCreatorMenu-layout-preview-window ${small ? 'small' : ''}`} + style={{ + gridTemplateColumns: `repeat(${altLayout ? altLayout.columns : this.columnsCount}, ${scaledDown(docWidth)}px`, + gridTemplateRows: `${scaledDown(docHeight)}px`, + gridAutoRows: `${scaledDown(docHeight)}px`, + rowGap: `${scaledDown(layout.yMargin)}px`, + columnGap: `${scaledDown(layout.xMargin)}px` + }}> + {this._layout.type === LayoutType.Stacked ? + <div + className='docCreatorMenu-layout-preview-item' + style={{ + width: scaledDown(docWidth), + height: scaledDown(docHeight), + fontSize: fontSize, + }} + > + All + </div> : + this.docsToRender.map(num => + <div + onMouseEnter={() => this._dataViz?.setSpecialHighlightedRow(num)} + onMouseLeave={() => this._dataViz?.setSpecialHighlightedRow(undefined)} + className='docCreatorMenu-layout-preview-item' + style={{ + width: scaledDown(docWidth), + height: scaledDown(docHeight), + fontSize: fontSize, + }} + > + {num} + </div> + )} + + </div> + </div> + ); + } + + get optionsMenuContents(){ + const layoutEquals = (layout: DataVizTemplateLayout) => { + + } //TODO: ADD LATER + + const layoutOption = (option: LayoutType, optStyle?: {}, specialFunc?: Function) => { + return ( + <div + className="docCreatorMenu-dropdown-option" + style={optStyle} + onPointerDown={e => this.setUpButtonClick(e, () => {specialFunc?.(); runInAction(() => this._layout.type = option)})}> + {option} + </div> + ); + } + + const selectionBox = (width: number, height: number, icon: string, specClass?: string, options?: JSX.Element[], manual?: boolean): JSX.Element => { + return (<div className='docCreatorMenu-option-container'> + <div className={`docCreatorMenu-option-title config ${specClass}`} style={{width: width * .4, height: height}}> + <FontAwesomeIcon icon={icon as any}/> + </div> + {manual ? <input className={`docCreatorMenu-input config ${specClass}`} style={{width: width * .6, height: height}}/> : + <select className={`docCreatorMenu-input config ${specClass}`} style={{width: width * .6, height: height}}> + {options} + </select> + } + </div>); + } + + const repeatOptions = [0, 1, 2, 3, 4, 5]; + + return ( + <div className='docCreatorMenu-menu-container'> + <div className='docCreatorMenu-option-container layout'> + <div className='docCreatorMenu-dropdown-hoverable'> + <div className="docCreatorMenu-option-title">{this._layout.type ? this._layout.type.toUpperCase() : 'Choose Layout'}</div> + <div className="docCreatorMenu-dropdown-content"> + {layoutOption(LayoutType.Stacked)} + {layoutOption(LayoutType.Grid, undefined, () => {if (!this._layout.columns) this._layout.columns = Math.ceil(Math.sqrt(this.docsToRender.length))})} + {layoutOption(LayoutType.Row)} + {layoutOption(LayoutType.Column)} + {layoutOption(LayoutType.Custom, {borderBottom: `0px`})} + </div> + </div> + <button + className='docCreatorMenu-menu-button preview-toggle' + onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this._layoutPreview = !this._layoutPreview))}> + <FontAwesomeIcon icon={this._layoutPreview ? 'minus' : 'magnifying-glass'}/> + </button> + </div> + {this._layout.type ? this.layoutConfigOptions: null} + {this._layoutPreview ? this.layoutPreviewContents(225) : null} + {selectionBox(60, 20, 'repeat', undefined, repeatOptions.map(num => <option onPointerDown={e => this._layout.repeat = num}>{`${num}x`}</option>))} + <hr className='docCreatorMenu-option-divider'/> + <div className='docCreatorMenu-general-options-container'> + <button + className='docCreatorMenu-save-layout-button' + onPointerDown={e => setupMoveUpEvents( this, e, returnFalse, emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + if (!this._selectedTemplate) return; + const layout: DataVizTemplateLayout = {template: this._selectedTemplate, layout: {type: this._layout.type, xMargin: this._layout.xMargin, yMargin:this._layout.yMargin, repeat: 0}, columns: this.columnsCount, rows: this.rowsCount, docsNumList: this.docsToRender}; + if (!this._savedLayouts.includes(layout)) { this._savedLayouts.push(layout) }; + }, 'make docs') + ) + }> + <FontAwesomeIcon icon='floppy-disk'/> + </button> + <button + className='docCreatorMenu-create-docs-button' + style={{backgroundColor: this.canMakeDocs ? '' : 'rgb(155, 155, 155)', border: this.canMakeDocs ? '' : 'solid 2px rgb(180, 180, 180)'}} + onPointerDown={e => setupMoveUpEvents( this, e, returnFalse, emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + if (!this._selectedTemplate) return; + const templateInfo: DataVizTemplateInfo = {doc: this._selectedTemplate, layout: this._layout, referencePos: {x: this._pageX + 450, y: this._pageY}, columns: this.columnsCount}; + this._dataViz?.createDocsFromTemplate(templateInfo); + }, 'make docs') + ) + }> + <FontAwesomeIcon icon='plus'/> + </button> + </div> + </div> + ); + } + + get renderSelectedViewType(){ + switch (this._menuContent){ + case 'templates': + return this.templatesPreviewContents; + case 'options': + return this.optionsMenuContents; + case 'saved': + return this.savedLayoutsPreviewContents; + default: + return undefined; + } + } + + get resizePanes(){ + const ref = this._ref?.getBoundingClientRect(); + const height: number = ref?.height ?? 0; + const width: number = ref?.width ?? 0; + + return [ + <div className='docCreatorMenu-resizer top' onPointerDown={this.onResizePointerDown} style={{width: width, left: 0, top: -7}}/>, + <div className='docCreatorMenu-resizer right' onPointerDown={this.onResizePointerDown} style={{height: height, left: width - 3, top: 0}}/>, + <div className='docCreatorMenu-resizer bottom' onPointerDown={this.onResizePointerDown} style={{width: width, left: 0, top: height - 3}}/>, + <div className='docCreatorMenu-resizer left' onPointerDown={this.onResizePointerDown} style={{height: height, left: -7, top: 0}}/>, + <div className='docCreatorMenu-resizer topRight' onPointerDown={this.onResizePointerDown} style={{left: width - 5, top: -10, cursor: 'nesw-resize'}}/>, + <div className='docCreatorMenu-resizer topLeft' onPointerDown={this.onResizePointerDown} style={{left: -10, top: -10, cursor: 'nwse-resize'}}/>, + <div className='docCreatorMenu-resizer bottomRight' onPointerDown={this.onResizePointerDown} style={{left: width - 5, top: height - 5, cursor: 'nwse-resize'}}/>, + <div className='docCreatorMenu-resizer bottomLeft' onPointerDown={this.onResizePointerDown} style={{left: -10, top: height - 5, cursor: 'nesw-resize'}}/> + ]; //prettier-ignore + } + + render() { + const topButton = (icon: string, opt: string, func: Function, tag: string) => { + return ( + <div className={`top-button-container ${tag} ${opt === this._menuContent ? 'selected' : ''}`}> + <div + className="top-button-content" + onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => {func()}))}> + <FontAwesomeIcon icon={icon as any}/> + </div> + </div> + ); + } + + const onPreviewSelected = () => {this._menuContent = 'templates'} + const onSavedSelected = () => {this._menuContent = 'saved'} + const onOptionsSelected = () => { + this._menuContent = 'options'; + if (!this._layout.columns) this._layout.columns = Math.ceil(Math.sqrt(this.docsToRender.length)); + } + + + return ( + <div className='docCreatorMenu'> + {!this._shouldDisplay ? undefined : + <> + {/* <div className='docCreatorMenu-placement-indicator' + style={{ + display: '', + left: this._indicatorX ?? this._pageX + 300, + top: this._indicatorY ?? this._pageY, + }} + onPointerMove={e => this.onPointerMove(e)} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + (e) => { + this._draggingIndicator = true; + this._startPos = {x: 0, y: 0}; + this._startPos.x = e.pageX - (this._ref?.getBoundingClientRect().left ?? 0); + this._startPos.y = e.pageY - (this._ref?.getBoundingClientRect().top ?? 0); + return true; + }, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + }, 'drag menu') + ) + }/> */} + <div + className="docCreatorMenu-cont" + ref={r => this._ref = r} + style={{ + display: '', + left: this._pageX, + top: this._pageY, + width: this._menuDimensions.width, + height: this._menuDimensions.height, + background: SnappingManager.userBackgroundColor, + color: SnappingManager.userColor, + }}> + {this.resizePanes} + <div + className='docCreatorMenu-menu' + onPointerDown={e => + setupMoveUpEvents( + this, + e, + (e) => { + this._dragging = true; + this._startPos = {x: 0, y: 0}; + this._startPos.x = e.pageX - (this._ref?.getBoundingClientRect().left ?? 0); + this._startPos.y = e.pageY - (this._ref?.getBoundingClientRect().top ?? 0); + document.addEventListener('pointermove', this.onDrag); + return true; + }, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + }, 'drag menu') + ) + } + > + <div className='docCreatorMenu-top-buttons-container'> + {topButton('table-cells', 'templates', onPreviewSelected, 'left')} + {topButton('bars', 'options', onOptionsSelected, 'middle')} + {topButton('floppy-disk', 'saved', onSavedSelected, 'right')} + </div> + <button + className='docCreatorMenu-menu-button close-menu' + onPointerDown={e => this.setUpButtonClick(e, this.closeMenu)}> + <FontAwesomeIcon icon={'minus'}/> + </button> + </div> + {this.renderSelectedViewType} + </div> + </> + } + </div> + ) + } +} + +export interface DataVizTemplateInfo { + doc: Doc; + layout: {type: LayoutType, xMargin: number, yMargin: number, repeat: number}; + columns: number; + referencePos: {x: number, y: number}; +} + +export interface DataVizTemplateLayout { + template: Doc; + docsNumList: number[]; + layout: {type: LayoutType, xMargin: number, yMargin: number, repeat: number}; + columns: number; + rows: number; +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index d2e82284e..559b8507c 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -1,6 +1,6 @@ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ /* eslint-disable jsx-a11y/no-static-element-interactions */ -import { Button, Type } from 'browndash-components'; +import { Button, Colors, Type } from 'browndash-components'; import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -38,6 +38,7 @@ interface TableBoxProps { left: number; }; docView?: () => DocumentView | undefined; + specHighlightedRow: number | undefined; } @observer @@ -441,7 +442,8 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { className={`tableBox-row ${this.columns[0]}`} onClick={e => this.tableRowClick(e, rowId)} style={{ - background: NumListCast(this._props.layoutDoc.dataViz_highlitedRows).includes(rowId) ? 'lightYellow' : NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowId) ? 'lightgrey' : '', + background: rowId === this._props.specHighlightedRow ? 'lightblue' : NumListCast(this._props.layoutDoc.dataViz_highlitedRows).includes(rowId) ? 'lightYellow' : NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowId) ? 'lightgrey' : '', + border: rowId === this._props.specHighlightedRow ? `solid 3px ${Colors.MEDIUM_BLUE}` : '' }}> {this.columns.map(col => { let colSelected = false; diff --git a/src/client/views/nodes/DocumentIcon.tsx b/src/client/views/nodes/DocumentIcon.tsx index ffd350e92..0b94ae4f7 100644 --- a/src/client/views/nodes/DocumentIcon.tsx +++ b/src/client/views/nodes/DocumentIcon.tsx @@ -25,19 +25,16 @@ export class DocumentIcon extends ObservableReactComponent<DocumentIconProps> { render() { const { view } = this._props; - const { left, top, right } = view.getBounds || { left: 0, top: 0, right: 0, bottom: 0 }; + const { left, top, right, bottom } = view.getBounds || { left: 0, top: 0, right: 0, bottom: 0 }; return ( <div className="documentIcon-outerDiv" - onPointerEnter={action(() => { this._hovered = true; })} // prettier-ignore - onPointerLeave={action(() => { this._hovered = false; })} // prettier-ignore style={{ pointerEvents: 'all', - opacity: this._hovered ? 0.3 : 1, position: 'absolute', background: SnappingManager.userBackgroundColor, - transform: `translate(${(left + right) / 2}px, ${top}px)`, + transform: `translate(${left}px, ${bottom - (bottom - top)/2}px)`, //**!** }}> <Tooltip title={<div>{StrCast(this._props.view.Document?.title)}</div>}> <p>d{this._props.index}</p> @@ -47,7 +44,7 @@ export class DocumentIcon extends ObservableReactComponent<DocumentIconProps> { } } -@observer +@observer export class DocumentIconContainer extends React.Component { public static getTransformer(): Transformer { const usedDocuments = new Set<number>(); diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index ce7cfa5f4..8edb37acc 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1058,6 +1058,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { public static getViews = (doc?: Doc) => Array.from(doc?.[DocViews] ?? []) as DocumentView[]; public static getFirstDocumentView: (toFind: Doc) => DocumentView | undefined; public static getDocumentView: (target: Doc | undefined, preferredCollection?: DocumentView) => Opt<DocumentView>; + public static getDocViewIndex: (target: Doc) => number; public static getContextPath: (doc: Opt<Doc>, includeExistingViews?: boolean) => Doc[]; public static getLightboxDocumentView: (toFind: Doc) => Opt<DocumentView>; public static showDocumentView: (targetDocView: DocumentView, options: FocusViewOptions) => Promise<void>; diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index 818d26956..40da661c0 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -118,7 +118,7 @@ export class FieldView extends React.Component<FieldViewProps> { const field = this.fieldval; // prettier-ignore if (field instanceof Doc) return <p> <b>{field.title?.toString()}</b></p>; - if (field === undefined) return <p>{'<null>'}</p>; + if (field === undefined) return <p>{''}</p>; if (field instanceof DateField) return <p>{field.date.toLocaleString()}</p>; if (field instanceof List) return <div> {field.map(f => Field.toString(f)).join(', ')} </div>; if (field instanceof WebField) return <p>{Field.toString(field.url.href)}</p>; diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 68c313480..92a5a1533 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -341,7 +341,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } @computed get paths() { - const field = Cast(this.dataDoc[this.fieldKey], ImageField, null); // retrieve the primary image URL that is being rendered from the data doc + const field = this.dataDoc[this.fieldKey] instanceof ImageField ? Cast(this.dataDoc[this.fieldKey], ImageField, null) : new ImageField(String(this.dataDoc[this.fieldKey])); // retrieve the primary image URL that is being rendered from the data doc const alts = DocListCast(this.dataDoc[this.fieldKey + '_alternates']); // retrieve alternate documents that may be rendered as alternate images const defaultUrl = new URL(ClientUtils.prepend('/assets/unknown-file-icon-hi.png')); const altpaths = diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index 66e210c03..bc6633f23 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -88,7 +88,7 @@ export class KeyValueBox extends ViewBoxBaseComponent<FieldViewProps>() { const type: 'computed' | 'script' | false = rawvalue.startsWith(':=') ? 'computed' : rawvalue.startsWith('$=') ? 'script' : false; rawvalue = type ? rawvalue.substring(2) : rawvalue; rawvalue = rawvalue.replace(/.*\(\((.*)\)\)/, 'dashCallChat(_setCacheResult_, this, `$1`)'); - const value = ["'", '"', '`'].includes(rawvalue.length ? rawvalue[0] : '') || !isNaN(rawvalue as any) ? rawvalue : '`' + rawvalue + '`'; + const value = (["'", '"', '`'].includes(rawvalue.length ? rawvalue[0] : '') || !isNaN(rawvalue as any)) ? rawvalue : '`' + rawvalue + '`'; let script = ScriptField.CompileScript(rawvalue, {}, true, undefined, DocumentIconContainer.getTransformer()); if (!script.compiled) { diff --git a/src/fields/SchemaHeaderField.ts b/src/fields/SchemaHeaderField.ts index 0a8dd1d9e..6fa94204a 100644 --- a/src/fields/SchemaHeaderField.ts +++ b/src/fields/SchemaHeaderField.ts @@ -12,6 +12,7 @@ export enum ColumnType { Image, RTF, Enumeration, + Equation, Any, } diff --git a/src/server/server_Initialization.ts b/src/server/server_Initialization.ts index 9183688c6..c3d31e74d 100644 --- a/src/server/server_Initialization.ts +++ b/src/server/server_Initialization.ts @@ -155,41 +155,51 @@ function proxyServe(req: any, requrl: string, response: any) { }; const retrieveHTTPBody = () => { // req.headers.cookie = ''; - req.pipe(request(requrl)) - .on('error', (e: any) => { - console.log(`CORS url error: ${requrl}`, e); - response.send(`<html><body bgcolor="red" link="006666" alink="8B4513" vlink="006666"> - <title>Error</title> - <div align="center"><h1>Failed to load: ${requrl} </h1></div> - <p>${e}</p> - </body></html>`); - }) - .on('response', (res: any) => { - res.headers; - const headers = Object.keys(res.headers); - const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; - headers.forEach(headerName => { - const header = res.headers[headerName]; - if (Array.isArray(header)) { - res.headers[headerName] = header.filter(h => !headerCharRegex.test(h)); - } else if (headerCharRegex.test(header || '')) { - delete res.headers[headerName]; - } else res.headers[headerName] = header; - if (headerName === 'content-encoding') { - wasinBrFormat = res.headers[headerName] === 'br'; - res.headers[headerName] = 'gzip'; - } - }); - res.headers['x-permitted-cross-domain-policies'] = 'all'; - res.headers['x-frame-options'] = ''; - res.headers['content-security-policy'] = ''; - // eslint-disable-next-line no-multi-assign - response.headers = response._headers = res.headers; - }) - .on('end', sendModifiedBody) - .pipe(htmlBodyMemoryStream) - .on('error', (e: any) => console.log('http body pipe error', e)); - }; + try { + const reqval = request(requrl); + req.pipe(reqval) + .on('error', (e: any) => { + console.log(`CORS url error: ${requrl}`, e); + response.send(`<html><body bgcolor="red" link="006666" alink="8B4513" vlink="006666"> + <title>Error</title> + <div align="center"><h1>Failed to load: ${requrl} </h1></div> + <p>${e}</p> + </body></html>`); + }) + .on('response', (res: any) => { + res.headers; + const headers = Object.keys(res.headers); + const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; + headers.forEach(headerName => { + const header = res.headers[headerName]; + if (Array.isArray(header)) { + res.headers[headerName] = header.filter(h => !headerCharRegex.test(h)); + } else if (headerCharRegex.test(header || '')) { + delete res.headers[headerName]; + } else res.headers[headerName] = header; + if (headerName === 'content-encoding') { + wasinBrFormat = res.headers[headerName] === 'br'; + res.headers[headerName] = 'gzip'; + } + }); + res.headers['x-permitted-cross-domain-policies'] = 'all'; + res.headers['x-frame-options'] = ''; + res.headers['content-security-policy'] = ''; + // eslint-disable-next-line no-multi-assign + response.headers = response._headers = res.headers; + }) + .on('end', sendModifiedBody) + .pipe(htmlBodyMemoryStream) + .on('error', (e: any) => console.log('http body pipe error', e)); + } catch (e) { + console.log("Request failed: ", e); + response.send(`<html><body bgcolor="red" link="006666" alink="8B4513" vlink="006666"> + <title>Error</title> + <div align="center"><h1>Failed to load: ${requrl} </h1></div> + <p>${e}</p> + </body></html>`); + } + } retrieveHTTPBody(); } |