diff options
Diffstat (limited to 'src/client/views')
30 files changed, 5406 insertions, 546 deletions
diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss index 4aaf2d03b..af0f717fe 100644 --- a/src/client/views/ContextMenu.scss +++ b/src/client/views/ContextMenu.scss @@ -162,3 +162,53 @@ border-radius: 5px; width: 100%; } + +.contextMenu-borderMenu { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 222px; + height: 300px; + background-color: white; + border: solid 1px black; + color: black; + z-index: 99999999; + + .top-bar { + height: 20px; + width: 100%; + display: flex; + + .close-menu { + margin-top: 0; + margin-bottom: 0; + margin-right: 0; + padding: 0; + margin-left: auto; + z-index: 999999999; + width: 20px; + height: 20px; + color: black; + background-color: transparent; + } + } + + .bottom-box{ + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 3px; + height: 100%; + width: 100%; + + .width-selector{ + width: 100px; + } + + .max-min-selector{ + height: 15px; + width: 30px; + } + } +} diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx index 399e8a238..98087e224 100644 --- a/src/client/views/ContextMenu.tsx +++ b/src/client/views/ContextMenu.tsx @@ -277,4 +277,4 @@ export class ContextMenu extends ObservableReactComponent<{ noexpand?: boolean } this._selectedIndex = Math.min(this.flatItems.length - 1, this._selectedIndex); } }; -} +}
\ No newline at end of file diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx index 5d31173e1..610032225 100644 --- a/src/client/views/ContextMenuItem.tsx +++ b/src/client/views/ContextMenuItem.tsx @@ -95,4 +95,4 @@ export class ContextMenuItem extends ObservableReactComponent<ContextMenuProps & const submenu = this._items.map(prop => <ContextMenuItem {...prop} key={prop.description} closeMenu={this._props.closeMenu} />); return this.props.event || this._props.noexpand ? this.renderItem(submenu) : <div className="contextMenu-inlineMenu">{submenu}</div>; } -} +}
\ No newline at end of file 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 23da5a666..9722b2d4b 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -7,6 +7,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 { /** @@ -51,6 +53,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; } /** @@ -62,18 +71,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) { @@ -81,11 +89,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 } @@ -104,7 +114,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); } @@ -116,6 +126,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 @@ -152,7 +164,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { case 'ArrowDown': case 'ArrowLeft': case 'ArrowRight': - e.stopPropagation(); + //e.stopPropagation(); break; case 'Shift': case 'Alt': @@ -176,9 +188,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 { @@ -187,7 +200,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { } } }; - + @action finalizeEdit(value: string, shiftDown: boolean, lostFocus: boolean, enterKey: boolean) { if (this._props.SetValue(value, shiftDown, enterKey)) { @@ -218,6 +231,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 @@ -235,11 +254,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()} @@ -265,16 +284,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() : '' as any + } + </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() @@ -291,18 +336,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, - }}> - {this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : (this._props.contents ?? '')} - </span> + {this.staticDisplay()} </div> ); } diff --git a/src/client/views/FieldsDropdown.tsx b/src/client/views/FieldsDropdown.tsx index 407031b40..176ac96b6 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 31d7e82a6..6feb6bd16 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -74,6 +74,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'; import { SmartDrawHandler } from './smartdraw/SmartDrawHandler'; import { InkTranscription } from './InkTranscription'; @@ -87,6 +88,7 @@ export class MainView extends ObservableReactComponent<object> { public static Live: boolean = false; private _docBtnRef = React.createRef<HTMLDivElement>(); + @observable private _keepContextMenuOpen: boolean = false; @observable private _windowWidth: number = 0; @observable private _windowHeight: number = 0; @observable private _dashUIWidth: number = 0; // width of entire main dashboard region including left menu buttons and properties panel (but not including the dashboard selector button row) @@ -278,6 +280,18 @@ export class MainView extends ObservableReactComponent<object> { library.add( ...[ + fa.faMinimize, + fa.faArrowsRotate, + 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, @@ -1087,6 +1101,7 @@ export class MainView extends ObservableReactComponent<object> { <PreviewCursor /> <TaskCompletionBox /> <ContextMenu /> + <DocCreatorMenu /> <ImageLabelHandler /> <SmartDrawHandler /> <AnchorMenu /> diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index d0c47875f..69c46052e 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -881,6 +881,12 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps doc[DocData].color = value || undefined; }); } + @computed get borderColor() { + const doc = this.selectedDoc; + const layoutDoc = doc ? Doc.Layout(doc) : doc; + return StrCast(layoutDoc); + } + set borderColor(value) { this.selectedDoc && (this.selectedDoc[DocData].color = value || undefined); } // prettier-ignore colorButton(value: string, type: string, setter: () => void) { return ( @@ -932,6 +938,10 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps return this.colorPicker(this.colorStk, (color: string) => { this.colorStk = color; }); // prettier-ignore } + @computed get borderColorPicker() { + return this.colorPicker(this.colorStk, (color: string) => { this.colorStk = color; }); // prettier-ignore + } + @computed get strokeAndFill() { return ( <div> 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/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 3545afcee..1e98695d1 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -52,14 +52,25 @@ export function styleFromLayoutString(doc: Doc, props: FieldViewProps, scale: nu return style; } -export function wavyBorderPath(pw: number, ph: number, inset: number = 0.05) { - return `M ${pw * 0.5} ${ph * inset} C ${pw * 0.6} ${ph * inset} ${pw * (1 - 2 * inset)} 0 ${pw * (1 - inset)} ${ph * inset} C ${pw} ${ph * (2 * inset)} ${pw * (1 - inset)} ${ph * 0.25} ${pw * (1 - inset)} ${ph * 0.3} C ${ - pw * (1 - inset) - } ${ph * 0.4} ${pw} ${ph * (1 - 2 * inset)} ${pw * (1 - inset)} ${ph * (1 - inset)} C ${pw * (1 - 2 * inset)} ${ph} ${pw * 0.6} ${ph * (1 - inset)} ${pw * 0.5} ${ph * (1 - inset)} C ${pw * 0.3} ${ph * (1 - inset)} ${pw * (2 * inset)} ${ph} ${ - pw * inset - } ${ph * (1 - inset)} C 0 ${ph * (1 - 2 * inset)} ${pw * inset} ${ph * 0.8} ${pw * inset} ${ph * 0.75} C ${pw * inset} ${ph * 0.7} 0 ${ph * (2 * inset)} ${pw * inset} ${ph * inset} C ${pw * (2 * inset)} 0 ${pw * 0.25} ${ph * inset} ${ - pw * 0.5 - } ${ph * inset}`; +export function border(doc: Doc, pw: number, ph: number, rad: number = 0, inset: number = 0) { + if (!rad) rad = 0; + const width = pw * inset; + const height = ph * inset; + + const radius = Math.min(rad, (pw - 2 * width) / 2, (ph - 2 * height) / 2); + + return ` + M ${width + radius} ${height} + L ${pw - width - radius} ${height} + A ${radius} ${radius} 0 0 1 ${pw - width} ${height + radius} + L ${pw - width} ${ph - height - radius} + A ${radius} ${radius} 0 0 1 ${pw - width - radius} ${ph - height} + L ${width + radius} ${ph - height} + A ${radius} ${radius} 0 0 1 ${width} ${ph - height - radius} + L ${width} ${height + radius} + A ${radius} ${radius} 0 0 1 ${width + radius} ${height} + Z + `; } let _filterOpener: () => void; @@ -186,18 +197,28 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & const rounding = StrCast(doc?.[fieldKey + 'borderRounding'], StrCast(doc?.layout_borderRounding, doc?._type_collection === CollectionViewType.Pile ? '50%' : '')); return (doc?.[StrCast(doc?.layout_fieldKey)] instanceof Doc || doc?.isTemplateDoc) ? StrCast(doc._layout_borderRounding,rounding) : rounding; } + // Doc.IsComicStyle(doc) && + // renderDepth && + // !doc?.layout_isSvg && + //case StyleProp. case StyleProp.BorderPath: { - const borderPath = Doc.IsComicStyle(doc) && - renderDepth && - !doc?.layout_isSvg && { path: wavyBorderPath(PanelWidth?.() || 0, PanelHeight?.() || 0), fill: wavyBorderPath(PanelWidth?.() || 0, PanelHeight?.() || 0, 0.08), width: 3 }; + const docWidth = Number(doc?._width); + const borderWidth = Number(StrCast(doc?.borderWidth)); + //console.log(borderWidth); + const ratio = borderWidth / docWidth; + const borderRadius = Number(StrCast(layoutDoc?._layout_borderRounding).replace('px', '')); + const radiusRatio = borderRadius / docWidth; + const radius = radiusRatio * ((2 * borderWidth) + docWidth); + + const borderPath = doc && border(doc, NumCast(doc._width), NumCast(doc._height), radius, -ratio/2 ?? 0); return !borderPath ? null : { - clipPath: `path('${borderPath.path}')`, + clipPath: `path('${borderPath}')`, jsx: ( <div key="border2" className="documentView-customBorder" style={{ pointerEvents: 'none' }}> <svg style={{ overflow: 'visible', height: '100%' }} viewBox={`0 0 ${PanelWidth?.()} ${PanelHeight?.()}`}> - <path d={borderPath.path} style={{ stroke: 'black', fill: 'transparent', strokeWidth: borderPath.width }} /> + <path d={borderPath} style={{ stroke: StrCast(doc?.borderColor), fill: 'transparent', strokeWidth: `${StrCast(doc?.borderWidth)}px` }} /> </svg> </div> ), 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 325628d53..0076caaf8 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx @@ -1,12 +1,12 @@ /* 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 { returnEmptyString, returnFalse, returnIgnore, returnNever, returnTrue, setupMoveUpEvents, smoothScroll } from '../../../../ClientUtils'; +import { ClientUtils, 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 { Doc, DocListCast, Field, FieldType, IdToDoc, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; import { DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; @@ -16,7 +16,6 @@ import { DocUtils } from '../../../documents/DocUtils'; import { Docs, DocumentOptions, FInfo } from '../../../documents/Documents'; import { DragManager } from '../../../util/DragManager'; import { dropActionType } from '../../../util/DropActionTypes'; -import { SettingsManager } from '../../../util/SettingsManager'; import { undoBatch, undoable } from '../../../util/UndoManager'; import { ContextMenu } from '../../ContextMenu'; import { EditableView } from '../../EditableView'; @@ -31,6 +30,22 @@ import { CollectionSubView, SubCollectionViewProps } from '../CollectionSubView' import './CollectionSchemaView.scss'; import { SchemaColumnHeader } from './SchemaColumnHeader'; import { SchemaRowBox } from './SchemaRowBox'; +import { ContextMenuProps } from '../../ContextMenuItem'; +import { DocumentManager } from '../../../util/DocumentManager'; +import { SchemaCellField } from './SchemaCellField'; +import { SnappingManager } from '../../../util/SnappingManager'; + +/** + * The schema view offers a spreadsheet-like interface for users to interact with documents. Within the schema, + * each doc is represented by its own row. Each column represents a field, for example the author or title fields. + * Users can apply varoius filters and sorts to columns to change what is displayed. The schemaview supports equations for + * cell linking. + * + * This class supports the main functionality for choosing which docs to render in the view, applying visual + * updates to rows and columns (such as user dragging or sort-related highlighting), applying edits to multiple cells + * at once, and applying filters and sorts to columns. It contains SchemaRowBoxes (which themselves contain SchemaTableCells, + * and SchemaCellFields) and SchemaColumnHeaders. + */ const { SCHEMA_NEW_NODE_HEIGHT } = require('../../global/globalCssVariables.module.scss'); // prettier-ignore @@ -48,16 +63,31 @@ const defaultColumnKeys: string[] = ['title', 'type', 'author', 'author_date', ' @observer export class CollectionSchemaView extends CollectionSubView() { - private _keysDisposer?: () => void; + 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: SubCollectionViewProps) { 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; @@ -79,15 +109,21 @@ export class CollectionSchemaView extends CollectionSubView() { @observable _newFieldType: ColumnType = ColumnType.Number; @observable _menuValue: string = ''; @observable _filterColumnIndex: number | undefined = undefined; - @observable _filterSearchValue: string = ''; + @observable _filterSearchValue: string = ''; //the current text inside the filter search bar, used to determine which values to display @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; //whether a column is being dragged by the user + @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 _containedDocs: Doc[] = []; //all direct children of the schema + @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; @@ -95,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)); @@ -107,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()); } @@ -130,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)); } @@ -138,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() { @@ -176,17 +216,33 @@ export class CollectionSchemaView extends CollectionSubView() { }, true ); + this._disposers.docdata = reaction( + () => DocListCast(this.dataDoc[this.fieldKey]), + (docs) => this._containedDocs = docs, + {fireImmediately: true} + ) + this._disposers.sortHighlight = reaction( + () => [this.sortField, this._containedDocs, 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._containedDocs = this._containedDocs.filter(d => d !== doc) + } + + rowIndex = (doc: Doc) => this.docsWithDrag.docs.indexOf(doc); @action onKeyDown = (e: KeyboardEvent) => { @@ -196,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); @@ -215,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); @@ -245,34 +301,26 @@ export class CollectionSchemaView extends CollectionSubView() { } break; case 'Backspace': { - undoable(() => this.removeDocument(this._selectedDocs), 'delete schema row'); + undoable(() => {this._selectedDocs.forEach(d => this._containedDocs.includes(d) && this.removeDoc(d));}, 'delete schema row'); break; } case 'Escape': { this.deselectAllCells(); break; } + case 'P': { + break; + } default: } } }; - @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?: string | number | boolean) => { - if (!this.documentKeys.includes(newKey)) { - this.addNewKey(newKey, defaultVal); - } + changeColumnKey = (index: number, newKey: string, defaultVal?: any) => { + 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; @@ -280,31 +328,36 @@ export class CollectionSchemaView extends CollectionSubView() { }; @undoBatch - addColumn = (key: string, defaultVal?: string | number | boolean) => { - 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?: string | number | boolean) => + 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.closeNewColumnMenu(); + } const currWidths = this.storedColumnWidths.slice(); currWidths.splice(index, 1); const newDesiredTableWidth = currWidths.reduce((w, cw) => w + cw, 0); @@ -312,24 +365,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: React.PointerEvent, 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; } @@ -367,14 +425,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.closeNewColumnMenu(); + 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])); @@ -382,7 +440,13 @@ export class CollectionSchemaView extends CollectionSubView() { return true; }; + /** + * Uses cursor x coordinate to calculate which index the column should be rendered/dropped in + * @param mouseX cursor x coordinate + * @returns column index + */ findColDropIndex = (mouseX: number) => { + const 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) { @@ -390,16 +454,35 @@ 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; }; /** - * Calculates the relative index of the cursor in the group of selected rows, ie. - * if five rows are selected and the cursor is in the middle row, its relative index would be 2. - * Used to align actively dragged documents properly with the cursor. - * @param mouseY the initial Y position of the cursor on drag + * Calculates the current index of dragged rows for dynamic rendering and drop logic. + * @param mouseY user's cursor position relative to the viewport + * @returns row index the dragged doc should be rendered/dropped in */ + findRowDropIndex = (mouseY: number): number => { + const rowHeight = CollectionSchemaView._rowHeight; + let index: number = 0; + this.rowHeights.reduce((total, curr, i) => { + if (total <= mouseY && total + curr >= mouseY) { + if (mouseY <= total + curr) index = i; + else index = i + 1; + } + return total + curr; + }, rowHeight); + + // fix index if selected rows are dragged out of bounds + let adjIndex = index - this._relCursorIndex; + const maxY = this.rowHeights.reduce((total, curr) => total + curr, 0) + rowHeight; + if (mouseY > maxY) adjIndex = this.childDocs.length - 1; + else if (adjIndex <= 0) adjIndex = 0; + + return adjIndex; + }; + @action setRelCursorIndex = (mouseY: number) => { this._mouseCoordinates.y = mouseY; // updates this.rowDropIndex computed value to overwrite the old cached value @@ -420,43 +503,196 @@ export class CollectionSchemaView extends CollectionSubView() { this._relCursorIndex = index; }; - findRowDropIndex = (mouseY: number) => { - const rowHeight = CollectionSchemaView._rowHeight; - let index: number = 0; - this.rowHeights.reduce((total, curr, i) => { - if (total <= mouseY && total + curr >= mouseY) { - if (mouseY <= total + curr) index = i; - else index = i + 1; - } - return total + curr; - }, rowHeight); - - // fix index if selected rows are dragged out of bounds - let adjIndex = index - this._relCursorIndex; - const maxY = this.rowHeights.reduce((total, curr) => total + curr, 0) + rowHeight; - if (mouseY > maxY) adjIndex = this.childDocs.length - 1; - else if (adjIndex <= 0) adjIndex = 0; - - return adjIndex; - }; - 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 : ''; }); - cellEles.slice(-1)[0].style.borderBottom = 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 = ''; + } + }); + }); + } + + /** + * Applies a gradient highlight to a sorted column. The direction of the gradient depends + * on whether the sort is ascending or descending. + * @param field the column being sorted + * @param descending whether the sort is descending or ascending; descending if true + */ + highlightSortedColumn = (field?: string, descending?: boolean) => { + let index = -1; + const highlightColors: string[] = []; + const rowCount: number = this._containedDocs.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]}` : ''; + }); + + } + + /** + * Gets the html element representing a cell so that styles can be applied on it. + * @param doc the cell's row document + * @param fieldKey the cell's column's field key + * @returns the html element representing the cell at the given location + */ + getCellElement = (doc: Doc, fieldKey: string) => { + const index = this.columnKeys.indexOf(fieldKey); + const cell = this._rowEles.get(doc).children[1].children[index]; + return cell; + } + + /** + * Given text in a cell, find references to other cells (for equations). + * @param text the text in the cell + * @returns the html cell elements referenced in the text. + */ + 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._containedDocs.includes(doc)) {cells.push([doc, field])} + }) + + return cells; + } + + /** + * Determines whether the rows above or below a given row have been + * selected, so selection highlights don't overlap. + * @param doc the document row to check + * @returns a boolean tuple where 0 is the row above, and 1 is the row below + */ + 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; + }); + } + + /** + * Highlights cells based on equation text in the cell currently being edited. + * Does not highlight selected cells (that's done directly in SchemaTableCell). + * @param text the equation + */ + 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}`; + const cell = this.getCellElement(doc, field); + this._cellHighlightColors.set(key, [colorStrings[0], colorStrings[1]]); + cell.style.border = colorStrings[0]; + cell.style.backgroundColor = colorStrings[1]; + } + } + + //Used in SchemaRowBox @action addRowRef = (doc: Doc, ref: HTMLDivElement) => this._rowEles.set(doc, ref); @@ -477,33 +713,48 @@ export class CollectionSchemaView extends CollectionSubView() { @action clearSelection = () => { + if (this._referenceSelectMode.enabled) return; DocumentView.DeselectAll(); this.deselectAllCells(); }; - selectRows = (doc: Doc, lastSelected: Doc) => { + + selectRow = (doc: Doc, lastSelected: Doc) => { const index = this.rowIndex(doc); const lastSelectedRow = this.rowIndex(lastSelected); 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); } } }; + //Used in SchemaRowBox + 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?.insertText(refToAdd, true); + editedField?.setupRefSelect(false); + return; + } + @action selectCell = (doc: Doc, col: number, shiftKey: boolean, ctrlKey: boolean) => { + this.closeNewColumnMenu(); 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; const lastSelected = Array.from(this._selectedDocs).lastElement(); - if (shiftKey && lastSelected && !this._selectedDocs.includes(doc)) this.selectRows(doc, lastSelected); + if (shiftKey && lastSelected && !this._selectedDocs.includes(doc)) this.selectRow(doc, lastSelected); else if (ctrlKey) { if (lastSelected && this._selectedDocs.includes(doc)) { DocumentView.DeselectView(DocumentView.getFirstDocumentView(doc)); @@ -513,8 +764,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 @@ -529,41 +778,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)); @@ -616,119 +848,44 @@ export class CollectionSchemaView extends CollectionSubView() { return undefined; }; - @computed get fieldDefaultInput() { - switch (this._newFieldType) { - case ColumnType.Number: - return ( - <input - type="number" - name="" - id="" - value={Number(this._newFieldDefault ?? 0)} - onPointerDown={e => e.stopPropagation()} - onChange={action(e => { - this._newFieldDefault = e.target.value; - })} - /> - ); - case ColumnType.Boolean: - return ( - <> - <input - type="checkbox" - value={this._newFieldDefault?.toString()} - onPointerDown={e => e.stopPropagation()} - onChange={action(e => { - this._newFieldDefault = e.target.checked; - })} - /> - {this._newFieldDefault ? 'true' : 'false'} - </> - ); - case ColumnType.String: - return ( - <input - type="text" - name="" - id="" - value={this._newFieldDefault?.toString() ?? ''} - onPointerDown={e => e.stopPropagation()} - onChange={action(e => { - this._newFieldDefault = e.target.value; - })} - /> - ); - default: - return undefined; - } - } - - onSearchKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'Enter': - this._menuKeys.length > 0 && this._menuValue.length > 0 - ? this.setKey(this._menuKeys[0]) - : runInAction(() => { - this._makeNewField = true; - }); - break; - case 'Escape': - this.closeColumnMenu(); - break; - default: - } - }; - @action - setKey = (key: string, defaultVal?: string | number | boolean) => { + 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.closeColumnMenu(); - }; + this.addColumn(this.columnKeys.indexOf(key), key, defaultVal); + this._makeNewColumn = false; + } else this.changeColumnKey(this._columnMenuIndex! | index!, key, defaultVal); - 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)); - } - return true; + this.closeNewColumnMenu(); }; - 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); - } - }); + /** + * Used in SchemaRowBox to set + * @param key + * @param value + * @returns + */ + 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; }; @action - openColumnMenu = (index: number, newCol: boolean) => { + openNewColumnMenu = (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; }; @action - closeColumnMenu = () => { + closeNewColumnMenu = () => { this._columnMenuIndex = undefined; }; @@ -743,32 +900,110 @@ 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.closeNewColumnMenu(); this.closeFilterMenu(); - ContextMenu.Instance.clearItems(); - ContextMenu.Instance.addItem({ - description: 'Change field', - event: () => this.openColumnMenu(index, false), + 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._containedDocs = 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._containedDocs = 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.openNewColumnMenu(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); }; + //used in schemacolumnheader @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); @@ -792,65 +1027,6 @@ 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> - ); - } - 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) if (!this._oldKeysWheel?.scrollTop && e.deltaY <= 0) e.preventDefault(); @@ -861,14 +1037,6 @@ export class CollectionSchemaView extends CollectionSubView() { return ( <div className="schema-key-search"> <div - className="schema-column-menu-button" - onPointerDown={action(e => { - e.stopPropagation(); - this._makeNewField = true; - })}> - + new field - </div> - <div className="schema-key-list" ref={r => { this._oldKeysWheel?.removeEventListener('wheel', this.onKeysPassiveWheel); @@ -886,11 +1054,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> @@ -903,17 +1068,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> ); } @@ -939,7 +1095,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()} @@ -955,7 +1111,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 @@ -970,51 +1126,177 @@ 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); + /** + * Gets docs contained by collections within the schema. Currently defunct. + * @param doc + * @param displayed + * @returns + */ + // 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; + // } + + /** + * Applies any filters active on the schema to filter out docs that don't match. + */ + @computed get filteredDocs() { + const childDocFilters = this.childDocFilters(); + const childFiltersByRanges = this.childDocRangeFilters(); + const searchDocs = this.searchFilterDocs(); + + const docsforFilter: Doc[] = []; + this._containedDocs.forEach(d => { + // dragging facets + const dragged = this._props.childFilters?.().some(f => f.includes(ClientUtils.noDragDocsFilter)); + if (dragged && SnappingManager.CanEmbed && DragManager.docsBeingDragged.includes(d)) return; + let notFiltered = d.z || Doc.IsSystem(d) || DocUtils.FilterDocs([d], this.unrecursiveDocFilters(), childFiltersByRanges, this.Document).length > 0; + if (notFiltered) { + notFiltered = (!searchDocs.length || searchDocs.includes(d)) && DocUtils.FilterDocs([d], childDocFilters, childFiltersByRanges, this.Document).length > 0; + const fieldKey = Doc.LayoutFieldKey(d); + const isAnnotatableDoc = d[fieldKey] instanceof List && !(d[fieldKey] as List<Doc>)?.some(ele => !(ele instanceof Doc)); + const docChildDocs = d[isAnnotatableDoc ? fieldKey + '_annotations' : fieldKey]; + const sidebarDocs = isAnnotatableDoc && d[fieldKey + '_sidebar']; + if (docChildDocs !== undefined || sidebarDocs !== undefined) { + let subDocs = [...DocListCast(docChildDocs), ...DocListCast(sidebarDocs)]; + if (subDocs.length > 0) { + let newarray: Doc[] = []; + notFiltered = notFiltered || (!searchDocs.length && DocUtils.FilterDocs(subDocs, childDocFilters, childFiltersByRanges, d).length); + while (subDocs.length > 0 && !notFiltered) { + newarray = []; + // eslint-disable-next-line no-loop-func + subDocs.forEach(t => { + const docFieldKey = Doc.LayoutFieldKey(t); + const isSubDocAnnotatable = t[docFieldKey] instanceof List && !(t[docFieldKey] as List<Doc>)?.some(ele => !(ele instanceof Doc)); + notFiltered = + notFiltered || ((!searchDocs.length || searchDocs.includes(t)) && ((!childDocFilters.length && !childFiltersByRanges.length) || DocUtils.FilterDocs([t], childDocFilters, childFiltersByRanges, d).length)); + DocListCast(t[isSubDocAnnotatable ? docFieldKey + '_annotations' : docFieldKey]).forEach(newdoc => newarray.push(newdoc)); + isSubDocAnnotatable && DocListCast(t[docFieldKey + '_sidebar']).forEach(newdoc => newarray.push(newdoc)); + }); + subDocs = newarray; + } + } + } + } + notFiltered && docsforFilter.push(d); + }); + return docsforFilter; + } + + /** + * Returns all child docs of the schema and child docs of contained collections that satisfy applied filters. + */ + @computed get docs() { + //let docsFromChildren: Doc[] = []; + + // Functionality for adding child docs + //const displayedCollections = this.childDocs.filter(d => d.type === 'collection' && d._childrenSharedWithSchema); + // displayedCollections.forEach(d => { + // let docsNotAlreadyDisplayed = this.subCollectionDocs(d, true).filter(dc => !this._containedDocs.includes(dc)); + // docsFromChildren = docsFromChildren.concat(docsNotAlreadyDisplayed); + // }); + + return this.filteredDocs;; + } + + /** + * Sorts docs first alphabetically and then numerically. + * @param field the column being sorted + * @param desc whether the sort is ascending or descending + * @param persistent whether the sort is applied persistently or is one-shot + * @returns + */ + 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._containedDocs = sortedDocs; + return sortedDocs; + } + + /** + * Returns all docs minus those currently being dragged by the user. + */ + @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(); - _oldWheel: HTMLDivElement | null = null; + 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.closeNewColumnMenu(); this.setColDrag(false)}}> <div ref={this._menuTarget} style={{ background: 'red', top: 0, left: 0, position: 'absolute', zIndex: 10000 }} /> <div className="schema-table" @@ -1027,26 +1309,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} @@ -1064,7 +1358,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; @@ -1187,7 +1481,7 @@ class CollectionSchemaViewDoc extends ObservableReactComponent<CollectionSchemaV interface CollectionSchemaViewDocsProps { schema: CollectionSchemaView; setRef: (ref: HTMLDivElement | null) => void; - childDocs: () => { docs: Doc[] }; + childDocs: () => Doc[]; rowHeight: () => number; } @@ -1196,7 +1490,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> @@ -1204,4 +1498,4 @@ class CollectionSchemaViewDocs extends React.Component<CollectionSchemaViewDocsP </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionSchema/SchemaCellField.tsx b/src/client/views/collections/collectionSchema/SchemaCellField.tsx new file mode 100644 index 000000000..84e7b62bf --- /dev/null +++ b/src/client/views/collections/collectionSchema/SchemaCellField.tsx @@ -0,0 +1,405 @@ +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 DOMPurify from 'dompurify'; + +/** + * The SchemaCellField renders text in schema cells while the user is editing, and updates the + * contents of the field based on user input. It handles some cell-side logic for equations, such + * as how equations are broken up within the text. + * + * The current implementation parses innerHTML to create spans based on the text in the cell. + * A more robust/safer approach would directly add elements in the react structure, but this + * has been challenging to implement. + */ + +export interface SchemaCellFieldProps { + contents: 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; + + constructor(props: SchemaCellFieldProps) { + super(props); + makeObservable(this); + setTimeout(() => { + this._unrenderedContent = this._props.GetValue() ?? ''; + this.setContent(this._unrenderedContent); + }); //must be moved to end of batch or else other docs aren't loaded, so render as d-1 in function + } + + get docIndex(){return DocumentView.getDocViewIndex(this._props.Document);} // prettier-ignore + + get selfRefPattern() { + return `d${this.docIndex}.${this._props.fieldKey}`; + } + + @computed get lastCharBeforeCursor() { + const pos = this.cursorPosition; + const content = this._unrenderedContent; + const text = this._unrenderedContent.substring(0, pos ?? content.length); + for (let i = text.length - 1; i > 0; --i) { + if (text.charCodeAt(i) !== 160 && text.charCodeAt(i) !== 32) { + return text[i]; + } + } + return null; + } + + @computed get refSelectConditionMet() { + const char = this.lastCharBeforeCursor; + return char === '+' || char === '*' || char === '/' || char === '%' || char === '='; + } + + componentDidMount(): void { + this._unrenderedContent = this._props.GetValue() ?? ''; + this.setContent(this._unrenderedContent, true); + this._disposers.editing = reaction( + () => this._editing, + editing => { + if (editing) { + this.setupRefSelect(this.refSelectConditionMet); + setTimeout(() => { + if (this._inputref?.innerText.startsWith('=') || this._inputref?.innerText.startsWith(':=')) { + this._overlayDisposer?.(); + this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + this._props.highlightCells?.(this._unrenderedContent); + this.setContent(this._unrenderedContent); + setTimeout(() => this.setCursorPosition(this._unrenderedContent.length)); + } + }); + } else { + this._overlayDisposer?.(); + this._overlayDisposer = undefined; + this._props.highlightCells?.(''); + this.setupRefSelect(false); + } + }, + { fireImmediately: true } + ); + this._disposers.fieldUpdate = reaction( + () => this._props.GetValue(), + fieldVal => { + console.log('Update: ' + this._props.Document.title, this._props.fieldKey, fieldVal); + this._unrenderedContent = fieldVal ?? ''; + this.finalizeEdit(false, false, false); + } + ); + } + + componentDidUpdate(prevProps: Readonly<SchemaCellFieldProps>) { + super.componentDidUpdate(prevProps); + if (this._editing && this._props.editing === false) { + this.finalizeEdit(false, true, false); + } else + runInAction(() => { + if (this._props.editing !== undefined) this._editing = this._props.editing; + }); + } + + _unmounted = false; + componentWillUnmount(): void { + this._unmounted = true; + console.log('Unmount: ' + this._props.Document.title, this._props.fieldKey); + this._overlayDisposer?.(); + Object.values(this._disposers).forEach(disposer => disposer?.()); + this.finalizeEdit(false, true, false); + } + + generateSpan = (text: string, cell: HTMLDivElement | undefined) => { + const selfRef = text === this.selfRefPattern; + return `<span style="text-decoration: ${selfRef ? 'underline' : 'none'}; text-decoration-color: red; color: ${selfRef ? 'gray' : cell?.style.borderTop.replace('2px solid', '')}">${text}</span>`; + }; + + makeSpans = (content: string) => { + let chunkedText = content; + + const pattern = /(this|d(\d+))\.(\w+)/g; + const matches: string[] = []; + let match: RegExpExecArray | null; + + const cells: Map<string, HTMLDivElement> = new Map(); + + while ((match = pattern.exec(content)) !== null) { + const cell = this._props.getCells(match[0]); + if (cell.length) { + matches.push(match[0]); + cells.set(match[0], cell[0]); + } + } + + matches.forEach((match: string) => { + chunkedText = chunkedText.replace(match, this.generateSpan(match, cells.get(match))); + }); + + return chunkedText; + }; + + /** + * Sets the rendered content of the cell to save user inputs. + * @param content the content to set + * @param restoreCursorPos whether the cursor should be set back to where it was rather than the 0th index; should usually be true + */ + @action + setContent = (content: string, restoreCursorPos?: boolean) => { + const pos = this.cursorPosition; + this._displayedContent = DOMPurify.sanitize(this.makeSpans(content)); + restoreCursorPos && setTimeout(() => this.setCursorPosition(pos)); + }; + + //Called from schemaview when a cell is selected to add a reference to the equation + /** + * Inserts text at the given index. + * @param text The text to append. + * @param atPos he index at which to insert the text. If empty, defaults to end. + */ + @action + insertText = (text: string, atPos?: boolean) => { + const content = this._unrenderedContent; + const cursorPos = this.cursorPosition; + const robustPos = cursorPos ?? content.length; + const newText = atPos ? content.slice(0, robustPos) + text + content.slice(cursorPos ?? content.length) : this._unrenderedContent.concat(text); + this.onChange(undefined, newText); + setTimeout(() => this.setCursorPosition(robustPos + text.length)); + }; + + @action + setIsFocused = (value: boolean) => { + const wasFocused = this._editing; + this._editing = value; + return wasFocused !== this._editing; + }; + + /** + * Gets the cursor's position index within the text being edited. + */ + get cursorPosition() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !this._inputref) return null; + + const range = selection.getRangeAt(0); + const adjRange = range.cloneRange(); + + adjRange.selectNodeContents(this._inputref); + adjRange.setEnd(range.startContainer, range.startOffset); + + return adjRange.toString().length; + } + + setCursorPosition = (position: number | null) => { + const selection = window.getSelection(); + if (!selection || position === null || !this._inputref) return; + + const range = document.createRange(); + range.setStart(this._inputref, 0); + range.collapse(true); + + let currentPos = 0; + const setRange = (nodes: NodeList) => { + for (let i = 0; i < nodes.length; ++i) { + const node = nodes[i]; + + if (node.nodeType === Node.TEXT_NODE) { + if (!node.textContent) return; + const nextPos = currentPos + node.textContent.length; + if (position <= nextPos) { + range.setStart(node, position - currentPos); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return true; + } + currentPos = nextPos; + } else if (node.nodeType === Node.ELEMENT_NODE && setRange(node.childNodes)) return true; + } + return false; + }; + + setRange(this._inputref.childNodes); + }; + + //This function checks if a visual update (eg. coloring a cell reference) should be made. It's meant to + //save on processing upkeep vs. constantly rerendering, but I think the savings are minimal for now + shouldUpdate = (prevVal: string, currVal: string) => { + if (this._props.getCells(currVal).length !== this._props.getCells(prevVal).length) return true; + }; + + onChange = (e: FormEvent<HTMLDivElement> | undefined, newText?: string) => { + const prevVal = this._unrenderedContent; + const targVal = newText ?? e!.currentTarget.innerText; // TODO: bang + if (!(targVal.startsWith(':=') || targVal.startsWith('='))) { + this._overlayDisposer?.(); + this._overlayDisposer = undefined; + } else if (!this._overlayDisposer) { + this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + } + this._unrenderedContent = targVal; + this._props.highlightCells?.(targVal); + if (this.shouldUpdate(prevVal, targVal)) this.setContent(targVal, true); + this.setupRefSelect(this.refSelectConditionMet); + }; + + setupRefSelect = (enabled: boolean) => { + const properties = this._props.refSelectModeInfo; + properties.enabled = enabled; + properties.currEditing = enabled ? this : undefined; + }; + + @action + onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.nativeEvent.defaultPrevented) return; // hack .. DashFieldView grabs native events, but react ignores stoppedPropagation and preventDefault, so we need to check it here + + switch (e.key) { + case 'Tab': + e.stopPropagation(); + this.finalizeEdit(e.shiftKey, false, false); + break; + case 'Backspace': + e.stopPropagation(); + break; + case 'Enter': + e.stopPropagation(); + if (!e.ctrlKey) { + this.finalizeEdit(e.shiftKey, false, true); + } + break; + case 'Escape': + e.stopPropagation(); + this._editing = false; + break; + case 'ArrowUp': + case 'ArrowDown': + case 'ArrowLeft': + case 'ArrowRight': // prettier-ignore + e.stopPropagation(); + setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0); + break; + case ' ': + e.stopPropagation(); + 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._unmounted) { + return; + } + if (this._unrenderedContent.replace(this.selfRefPattern, '') !== this._unrenderedContent) { + if (this._dependencyMessageShown) { + this._dependencyMessageShown = false; + } else alert(`Circular dependency detected. Please update the field at ${this.selfRefPattern}.`); + this._dependencyMessageShown = true; + return; + } + + this.setContent(this._unrenderedContent); + + if (!this._props.SetValue(this._unrenderedContent, shiftDown, enterKey) && !lostFocus) { + setTimeout(action(() => (this._editing = true))); + } + this._editing = false; + }; + + staticDisplay = () => { + return <span className="editableView-static">{this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : ''}</span>; + }; + + renderEditor = () => { + return ( + <div + contentEditable + className="schemaField-editing" + ref={r => { + this._inputref = r; + }} + style={{ cursor: 'text', outline: 'none', overflow: 'auto', minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20 }} + onBlur={() => (this._props.refSelectModeInfo.enabled ? setTimeout(() => this.setIsFocused(true), 1000) : this.finalizeEdit(false, true, false))} + autoFocus + onInput={this.onChange} + onKeyDown={this.onKeyDown} + onPointerDown={e => { + e.stopPropagation(); + setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0); + }} //timeout callback ensures that refSelectMode is properly set + onClick={e => e.stopPropagation} + onPointerUp={e => e.stopPropagation} + onPointerMove={e => { + e.stopPropagation(); + e.preventDefault(); + }} + dangerouslySetInnerHTML={{ __html: this._displayedContent }}></div> + ); + }; + + render() { + const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n'); + if (this._editing && gval !== undefined) { + return <div className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`}>{this.renderEditor()}</div>; + } else + return this._props.contents instanceof ObjectField ? null : ( + <div + className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`} + style={{ + minHeight: '10px', + whiteSpace: this._props.oneLine ? 'nowrap' : 'pre-line', + width: '100%', + }} + onClick={this.onClick}> + {this.staticDisplay()} + </div> + ); + } +} diff --git a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx index e0ed8d01e..c5cdac8af 100644 --- a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx +++ b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx @@ -1,78 +1,248 @@ /* 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 { 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, returnEmptyDoclist } 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 { undoable } from '../../../util/UndoManager'; +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: React.PointerEvent, index: number) => void; + resizeColumn: (e: React.PointerEvent, index: number, rightSide: boolean) => void; dragColumn: (e: PointerEvent, 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.openNewColumnMenu(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={''} + 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; + } + this._props.finishEdit?.(); + return true; + }, 'edit column header')} + /> + </div> + } + + public static isDefaultField = (key: string) => { + const defaultPattern = /EmptyColumnKey/; + const 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, + () => {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> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx index a7e0e916b..a8a4ef2c2 100644 --- a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx +++ b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx @@ -1,10 +1,8 @@ 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 { FaExternalLinkAlt } from 'react-icons/fa'; import { returnFalse, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; import { Doc } from '../../../../fields/Doc'; @@ -12,12 +10,20 @@ import { BoolCast } from '../../../../fields/Types'; import { Transform } from '../../../util/Transform'; import { undoable } from '../../../util/UndoManager'; import { ViewBoxBaseComponent } from '../../DocComponent'; -import { Colors } from '../../global/globalEnums'; import { FieldView, FieldViewProps } from '../../nodes/FieldView'; import { OpenWhere } from '../../nodes/OpenWhere'; import { CollectionSchemaView } from './CollectionSchemaView'; import './CollectionSchemaView.scss'; import { SchemaTableCell } from './SchemaTableCell'; +import { ContextMenu } from '../../ContextMenu'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; + +/** + * The SchemaRowBox renders a doc as a row of cells, with each cell representing + * one field value of the doc. It mostly handles communication from the SchemaView + * to each SchemaCell, passing down necessary functions are props. + */ interface SchemaRowBoxProps extends FieldViewProps { rowIndex: number; @@ -28,6 +34,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); @@ -44,29 +51,77 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { return this.schemaView.Document; } - @computed get rowIndex() { - return this.schemaView?.rowIndex(this.Document) ?? -1; - } - componentDidMount(): void { 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', + }); + // Defunct option to add child docs of collections to the main schema + // const childDocs = DocListCast(this.Document[Doc.LayoutFieldKey(this.Document)]) + // if (this.Document.type === 'collection' && 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: string, value: string) => this.schemaView?.setColumnValues(field, value) ?? false; - setSelectedColumnValues = (field: string, value: string) => 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 +131,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 +146,25 @@ 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} + highlightCells={this.highlightCells} + isolatedSelection={this.isolatedSelection} key={key} + rowSelected={this._props.isSelected} Document={this.Document} col={index} fieldKey={key} @@ -146,7 +178,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={() => { @@ -161,4 +192,4 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx index 22506cac1..c05382ce0 100644 --- a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx +++ b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx @@ -12,7 +12,7 @@ import Select from 'react-select'; import { ClientUtils, StopEvent, returnEmptyFilter, returnFalse, returnZero } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; import { DateField } from '../../../../fields/DateField'; -import { Doc, DocListCast, Field, returnEmptyDoclist } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, IdToDoc, returnEmptyDoclist } from '../../../../fields/Doc'; import { RichTextField } from '../../../../fields/RichTextField'; import { ColumnType } from '../../../../fields/SchemaHeaderField'; import { BoolCast, Cast, DateCast, DocCast, FieldValue, StrCast, toList } from '../../../../fields/Types'; @@ -31,6 +31,14 @@ import { FieldViewProps } from '../../nodes/FieldView'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; import { FInfotoColType } from './CollectionSchemaView'; import './CollectionSchemaView.scss'; +import { SchemaColumnHeader } from './SchemaColumnHeader'; +import { SchemaCellField } from './SchemaCellField'; + +/** + * SchemaTableCells make up the majority of the visual representation of the SchemaView. + * They are rendered for each cell in the SchemaView, and each represents one field value + * of a doc. Editing the content of the cell changes the corresponding doc's field value. + */ export interface SchemaTableCellProps { Document: Doc; @@ -47,7 +55,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) @@ -56,23 +63,44 @@ 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; + 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; @@ -82,15 +110,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, @@ -121,33 +146,66 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro return { color, textDecoration, fieldProps, cursor, pointerEvents }; } + 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; + const 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());}) + + 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={''} + 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')} @@ -183,23 +241,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> ); } @@ -441,4 +524,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 423a2d6ef..962a21dbb 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -84,6 +84,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 df6e74d85..12196f290 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, inheritParentAcls } from '../../../../fields/util'; import { DocUtils } from '../../../documents/DocUtils'; import { DocumentType } from '../../../documents/DocumentTypes'; import { Docs } from '../../../documents/Documents'; @@ -32,6 +32,18 @@ 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 { Col, DataVizTemplateInfo, DataVizTemplateLayout, DocCreatorMenu, TemplateFieldSize, LayoutType, TemplateFieldType } from './DocCreatorMenu'; +import { CollectionFreeFormView, MarqueeView } 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'; +import { Id } from '../../../../fields/FieldSymbols'; +import { GPTCallType, gptAPICall } from '../../../apis/gpt/GPT'; +import { TbSortDescendingShapes } from 'react-icons/tb'; export enum DataVizView { TABLE = 'table', @@ -42,6 +54,7 @@ export enum DataVizView { @observer export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { + private _urlError: boolean = false; private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); private _marqueeref = React.createRef<MarqueeAnnotator>(); private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); @@ -50,7 +63,11 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { sidebarAddDoc: ((doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean) | undefined; crop: ((region: Doc | undefined, addCrop?: boolean) => Doc | undefined) | undefined; @observable _marqueeing: number[] | undefined = undefined; - @observable _savedAnnotations = new ObservableMap<number, (HTMLDivElement & { marqueeing?: boolean })[]>(); + @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + @observable _specialHighlightedRow: number | undefined = undefined; + @observable GPTSummary: ObservableMap<string, {desc?: string, type?: TemplateFieldType, size?: TemplateFieldSize}> | undefined = undefined; + @observable colsInfo: ObservableMap<string, Col> = new ObservableMap(); + @observable _GPTLoading: boolean = false; constructor(props: FieldViewProps) { super(props); @@ -100,8 +117,17 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // all CSV records in the dataset (that aren't an empty row) @computed.struct get records() { - const records = DataVizBox.dataset.get(CsvCast(this.dataDoc[this.fieldKey]).url.href); - return records?.filter(record => Object.keys(record).some(key => record[key])) ?? []; + try { + const records = DataVizBox.dataset.get(CsvCast(this.dataDoc[this.fieldKey]).url.href); + this._urlError = false; + return records?.filter(record => Object.keys(record).some(key => record[key])) ?? []; + } catch (e){ + this._urlError = true; + const data: { [key: string]: string; }[] = [ + { error: "Data not found"}, + ]; + return data; + } } // currently chosen visualization type: line, pie, histogram, table @@ -125,6 +151,61 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this.layoutDoc._dataViz_titleCol = titleCol; }; + @action setSpecialHighlightedRow = (row: number | undefined) => { + this._specialHighlightedRow = row; + } + + @action setColumnType = (colTitle: string, type: TemplateFieldType) => { + const colInfo = this.colsInfo.get(colTitle); + if (colInfo) { + colInfo.type = type; + } else { + this.colsInfo.set(colTitle, {title: colTitle, desc: '', type: type, sizes: [TemplateFieldSize.MEDIUM]}) + } + } + + @action modifyColumnSizes = (colTitle: string, size: TemplateFieldSize, valid: boolean) => { + const column = this.colsInfo.get(colTitle); + if (column) { + if (!valid && column.sizes.includes(size)) { + column.sizes.splice(column.sizes.indexOf(size), 1); + } else if (valid && !column.sizes.includes(size)) { + column.sizes.push(size); + } + } else { + this.colsInfo.set(colTitle, {title: colTitle, desc: '', type: TemplateFieldType.UNSET, sizes: [size]}) + } + } + + @action setColumnTitle = (colTitle: string, newTitle: string) => { + const colInfo = this.colsInfo.get(colTitle); + if (colInfo) { + colInfo.title = newTitle; + console.log(colInfo.title) + } else { + this.colsInfo.set(colTitle, {title: newTitle, desc: '', type: TemplateFieldType.UNSET, sizes: []}) + } + } + + @action setColumnDesc = (colTitle: string, desc: string) => { + const colInfo = this.colsInfo.get(colTitle); + if (colInfo) { + if (!desc) { colInfo.desc = this.GPTSummary?.get(colTitle)?.desc ?? ''; } + else { colInfo.desc = desc; } + } else { + this.colsInfo.set(colTitle, {title: colTitle, desc: desc, type: TemplateFieldType.UNSET, sizes: []}) + } + } + + @action setColumnDefault = (colTitle: string, cont: string) => { + const colInfo = this.colsInfo.get(colTitle); + if (colInfo) { + colInfo.defaultContent = cont; + } else { + this.colsInfo.set(colTitle, {title: colTitle, desc: '', type: TemplateFieldType.UNSET, sizes: [], defaultContent: cont}) + } + } + @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 +227,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 @@ -272,7 +354,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { componentDidMount() { this._props.setContentViewBox?.(this); - if (!DataVizBox.dataset.has(CsvCast(this.dataDoc[this.fieldKey]).url.href)) this.fetchData(); + if (!this._urlError) { if (!DataVizBox.dataset.has(CsvCast(this.dataDoc[this.fieldKey]).url.href)) this.fetchData() }; this._disposers.datavis = reaction( () => { if (this.layoutDoc.dataViz_schemaLive === undefined) this.layoutDoc.dataViz_schemaLive = true; @@ -333,6 +415,10 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }, { fireImmediately: true } ); + this._disposers.contentSummary = reaction( + () => this.records, + () => this.updateGPTSummary() + ); } fetchData = () => { @@ -359,7 +445,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 +512,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 ?? []; 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 +538,198 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { GPTPopup.Instance.generateDataAnalysis(); }); + getColSummary = (): string => { + let possibleIds: number[] = this.records.map((_, index) => index); + + for (let i = possibleIds.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [possibleIds[i], possibleIds[j]] = [possibleIds[j], possibleIds[i]]; + } + + const rowsToCheck = possibleIds.slice(0, Math.min(10, this.records.length)); + + let prompt: string = 'Col titles: '; + + const cols = Array.from(Object.keys(this.records[0])).filter(header => header !== '' && header !== undefined); + + cols.forEach((col, i) => { + prompt += `Col #${i}: ${col} ------` + }) + + prompt += '----------- Rows: ' + + rowsToCheck.forEach((row, i) => { + prompt += `Row #${row}: ` + cols.forEach(col => { + prompt += `${col}: ${this.records[row][col]} -----` + }) + }) + + return prompt; + } + + updateColDefaults = () => { + let possibleIds: number[] = this.records.map((_, index) => index); + + for (let i = possibleIds.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [possibleIds[i], possibleIds[j]] = [possibleIds[j], possibleIds[i]]; + } + + const rowToCheck = possibleIds[0]; + + const cols = Array.from(Object.keys(this.records[0])).filter(header => header !== '' && header !== undefined); + + cols.forEach(col => { + this.setColumnDefault(col, `${this.records[rowToCheck][col]}`) + }); + } + + updateGPTSummary = async () => { + this._GPTLoading = true; + + this.updateColDefaults(); + + const prompt = this.getColSummary(); + + const cols = Array.from(Object.keys(this.records[0])).filter(header => header !== '' && header !== undefined); + cols.forEach(col => { + if (!this.colsInfo.get(col)) this.colsInfo.set(col, {title: col, desc: '', sizes: [], type: TemplateFieldType.UNSET}); + }); + + try { + const [res1, res2] = await Promise.all([ + gptAPICall(prompt, GPTCallType.VIZSUM), + gptAPICall('Info:' + prompt, GPTCallType.VIZSUM2) + ]); + + if (res1) { + this.GPTSummary = new ObservableMap(); + const descs: { [col: string]: string } = JSON.parse(res1); + for (const [key, val] of Object.entries(descs)) { + this.GPTSummary.set(key, { desc: val }); + if (!this.colsInfo.get(key)?.desc) this.setColumnDesc(key, val); + } + } + + if (res2) { + !this.GPTSummary && (this.GPTSummary = new ObservableMap()); + const info: { [col: string]: { type: TemplateFieldType, size: TemplateFieldSize } } = JSON.parse(res2); + for (const [key, val] of Object.entries(info)) { + const colSummary = this.GPTSummary.get(key); + if (colSummary) { + colSummary.size = val.size; + colSummary.type = val.type; + this.setColumnType(key, val.type); + this.modifyColumnSizes(key, val.size, true); + } + } + } + } catch (err) { + console.error(err); + } + + } + + 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) { + 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 +783,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..9d82ada37 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu.scss @@ -0,0 +1,1036 @@ +.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: 25px; + height: 25px; + background: whitesmoke; + background-color: rgb(50, 50, 50); + border-radius: 5px; + border: 1px solid rgb(180, 180, 180); + padding: 0px; + font-size: 13px; + //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: 95%; + margin-top: 10px; + margin-bottom: 10px; + + &.full { + width: 100%; + } +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// 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-templates-view { + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow-y: scroll; + //align-items: flex-start; + margin: 5px; + margin-top: 0px; + width: calc(100% - 10px); + height: calc(100% - 30px); + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.docCreatorMenu-preview-container { + display: grid; + grid-template-columns: repeat(2, 140px); + grid-template-rows: 140px; + grid-auto-rows: 141px; + overflow-y: scroll; + margin: 0px; + margin-top: 0px; + width: 100%; + height: 100%; +} + +.docCreatorMenu-expanded-template-preview { + position: relative; + width: 100%; + height: 100%; + + .right-buttons-panel { + display: flex; + flex-direction: column; + justify-content: flex-start; + height: 100%; + width: 40px; + position: absolute; + right: 0px; + top: 0px; + padding: 5px; + gap: 2px; + } +} + +.docCreatorMenu-preview-window { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 113px; + height: 113px; + margin-top: 10px; + margin-left: 10px; + border: 1px solid rgb(163, 163, 163); + border-radius: 5px; + box-shadow: 5px 5px rgb(29, 29, 31); + flex: 0 0 auto; + + &:hover{ + background-color: rgb(72, 72, 73); + } + + &.empty { + font-size: 35px; + + &.GPT { + margin-top: 0px; + } + } + + .option-button { + display: none; + height: 25px; + width: 25px; + margin: 0px; + background: none; + border: 0px; + padding: 0px; + font-size: 15px; + + &.right { + position: absolute; + bottom: 0px; + right: 0px; + } + + &.left { + position: absolute; + bottom: 0px; + left: 0px; + } + + &.top-left { + position: absolute; + top: 0px; + left: 0px; + } + } + + &:hover .option-button { + display: block; + } + +} + +.docCreatorMenu-preview-image{ + background-color: transparent; + height: 100px; + width: 100px; + display: block; + object-fit: contain; + border-radius: 5px; + + &.expanded { + height: 100%; + width: 100%; + } +} + +.docCreatorMenu-section { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + margin: 0px; + margin-top: 0px; + margin-bottom: 0px; + width: 100%; + height: 200; + flex: 0 0 auto; +} + +.docCreatorMenu-GPT-options-container { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + position: relative; + width: auto; + margin: 0px; + margin-top: 5px; + padding: 0px; +} + +.docCreatorMenu-templates-preview-window { + display: flex; + flex-direction: row; + //justify-content: center; + align-items: center; + overflow-y: scroll; + position: relative; + height: 125px; + width: calc(100% - 10px); + -ms-overflow-style: none; + scrollbar-width: none; + + .loading-spinner { + justify-self: center; + } +} + +.divvv{ + width: 200; + height: 200; + border: solid 1px white; +} + +.docCreatorMenu-section-topbar { + position: relative; + display: flex; + flex-direction: row; + width: 100%; +} + +.section-reveal-options { + margin-top: 0px; + margin-bottom: 0px; + margin-right: 0px; + margin-left: auto; + border: 0px; + background: none; + + &.float-right { + float: right; + } +} + +.docCreatorMenu-section-title { + border: 1px solid rgb(163, 163, 163); + border-top: 0px; + border-left: 0px; + border-bottom-right-radius: 5px; + font-size: 12px; + padding: 2px; + padding-left: 3px; + padding-right: 3px; + margin-bottom: 3px; +} + +.docCreatorMenu-GPT-generate { + height: 30px; + width: 30px; + background-color: rgb(176, 229, 149); + border: 1px solid rgb(126, 219, 80); + border-radius: 5px; + padding: 0px; + font-size: 14px; + color: white; + letter-spacing: 1px; + flex: 0 0 auto; + + &:hover { + background-color: rgb(129, 223, 83); + border: 2px solid rgb(80, 185, 28); + } +} + +.docCreatorMenu-GPT-prompt-input { + width: 140px; + height: 25px; + overflow-y: scroll; + border: 1px solid rgb(180, 180, 180); + background-color: rgb(35, 35, 35); + border-radius: 3px; + padding-left: 4px; +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// 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; + //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; + } +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// DocCreatorMenu dashboard CSS +//-------------------------------------------------------------------------------------------------------------------------------------------- + +.docCreatorMenu-dashboard-view { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow-y: hidden; + //align-items: flex-start; + margin: 5px; + margin-top: 0px; + width: calc(100% - 10px); + height: calc(100% - 30px); + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; + -ms-overflow-style: none; + scrollbar-width: none; + + .panels-container { + height: 100%; + width: 100%; + flex-direction: column; + justify-content: flex-start; + overflow-y: scroll; + } + + .topbar { + height: 30px; + width: 100%; + background-color: rgb(50, 50, 50); + } + +// .field-panel { +// position: relative; +// display: flex; +// // align-items: flex-start; +// flex-direction: column; +// gap: 5px; +// padding: 5px; +// height: 100px; +// //width: 100%; +// border: 1px solid rgb(180, 180, 180); +// margin: 5px; +// margin-top: 0px; +// border-radius: 3px; +// flex: 0 0 auto; + +// .properties-wrapper { +// display: flex; +// flex-direction: row; +// align-items: flex-start; +// gap: 5px; + +// .field-property-container { +// background-color: rgb(40, 40, 40); +// border: 1px solid rgb(100, 100, 100); +// border-radius: 3px; +// width: 30%; +// height: 25px; +// padding-left: 3px; +// align-items: center; +// color: whitesmoke; +// } + +// .field-type-selection-container { +// display: flex; +// flex-direction: row; +// align-items: center; +// background-color: rgb(40, 40, 40); +// border: 1px solid rgb(100, 100, 100); +// border-radius: 3px; +// width: 31%; +// height: 25px; +// padding-left: 3px; +// color: whitesmoke; + +// .placeholder { +// color: gray; +// } + +// &:hover .placeholder { +// display: none; +// } + +// .bubbles { +// display: none; +// } + +// .text { +// margin-top: 5px; +// margin-bottom: 5px; +// } + +// &:hover .bubbles { +// display: flex; +// flex-direction: row; +// align-items: flex-start; +// } + +// &:hover .type-display { +// display: none; +// } + +// .bubble { +// margin: 5px; +// } + +// &:hover .bubble { +// margin-top: 7px; +// } +// } +// } + +// .field-description-container { +// background-color: rgb(40, 40, 40); +// border: 1px solid rgb(100, 100, 100); +// border-radius: 3px; +// width: 100%; +// height: 100%; +// resize: none; + +// ::-webkit-scrollbar-track { +// background: none; +// } +// } + +// .top-right { +// position: absolute; +// top: 0px; +// right: 0px; +// } +// } +// } + + .field-panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + height: 285px; + width: calc(100% - 10px); + border: 1px solid rgb(180, 180, 180); + margin: 5px; + margin-top: 0px; + margin-bottom: 10px; + border-radius: 3px; + flex: 0 0 auto; + gap: 25px; + background-color: rgb(60, 60, 60); + + .top-bar { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-bottom: 1px solid rgb(180, 180, 180); + border-top-right-radius: 5px; + border-top-left-radius: 5px; + width: 100%; + height: 20px; + background-color: rgb(50, 50, 50); + color: rgb(168, 167, 167); + + .field-title { + color: whitesmoke; + } + } + + .opts-bar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; + + .opt-box { + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; + width: 40%; + height: 50px; + margin-right: 4%; + margin-left: 4%; + box-shadow: 5px 5px rgb(29, 29, 31); + } + + .content { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + height: calc(100% - 20px); + width: 100%; + background-color: rgb(50, 50, 50); + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + resize: none; + + .bubbles { + display: none; + } + + .text { + margin-right: 5px; + } + + &:hover .bubbles { + display: flex; + flex-direction: row; + align-items: flex-start; + } + + &:hover .type-display { + display: none; + } + + .bubble { + margin: 3px; + } + } + } + + .sizes-box { + width: 88%; + height: 60px; + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; + background-color: rgb(50, 50, 50); + box-shadow: 5px 5px rgb(29, 29, 31); + + .content { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + height: calc(100% - 20px); + width: 100%; + background-color: rgb(50, 50, 50); + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + + .text { + margin-right: 9px; + } + + .bubbles { + display: flex; + flex-direction: row; + align-items: center; + } + + .bubble { + margin: 3px; + margin-right: 4px; + } + } + } + + .desc-box { + width: 88%; + height: 50px; + border: 1px solid rgb(180, 180, 180); + border-radius: 5px; + background-color: rgb(50, 50, 50); + box-shadow: 5px 5px rgb(29, 29, 31); + + .content { + height: calc(100% - 20px); + width: 100%; + background-color: rgb(50, 50, 50); + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + resize: none; + + } + } + + } + +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx new file mode 100644 index 000000000..43e9248a7 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx @@ -0,0 +1,2335 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Colors } from 'browndash-components'; +import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import { IDisposer } from 'mobx-utils'; +import * as React from 'react'; +import ReactLoading from 'react-loading'; +import { ClientUtils, returnFalse, setupMoveUpEvents } from '../../../../ClientUtils'; +import { emptyFunction } from '../../../../Utils'; +import { Doc, NumListCast, StrListCast } from '../../../../fields/Doc'; +import { Id } from '../../../../fields/FieldSymbols'; +import { DocCast, ImageCast, StrCast } from '../../../../fields/Types'; +import { ImageField } from '../../../../fields/URLField'; +import { Networking } from '../../../Network'; +import { GPTCallType, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT'; +import { Docs } from '../../../documents/Documents'; +import { DragManager } from '../../../util/DragManager'; +import { MakeTemplate } from '../../../util/DropConverter'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { UndoManager, undoable } from '../../../util/UndoManager'; +import { LightboxView } from '../../LightboxView'; +import { ObservableReactComponent } from '../../ObservableReactComponent'; +import { CollectionFreeFormView } from '../../collections/collectionFreeForm/CollectionFreeFormView'; +import { DocumentView, DocumentViewInternal } from '../DocumentView'; +import { FieldViewProps } from '../FieldView'; +import { OpenWhere } from '../OpenWhere'; +import { DataVizBox } from './DataVizBox'; +import './DocCreatorMenu.scss'; + +export enum LayoutType { + Stacked = 'stacked', + Grid = 'grid', + Row = 'row', + Column = 'column', + Custom = 'custom', +} + +@observer +export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { + static Instance: DocCreatorMenu; + + private _disposers: { [name: string]: IDisposer } = {}; + + private _ref: HTMLDivElement | null = null; + + @observable _templateDocs: Doc[] = []; + @observable _selectedTemplate: Doc | undefined = undefined; + @observable _columns: Col[] = []; + @observable _selectedCols: { title: string; type: string; desc: string }[] | 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 _expandedPreview: { icon: ImageField; doc: Doc } | undefined = undefined; + + @observable _suggestedTemplates: Doc[] = []; + @observable _GPTOpt: boolean = false; + @observable _userPrompt: string = ''; + @observable _callCount: number = 0; + @observable _GPTLoading: boolean = false; + + @observable _pageX: number = 0; + @observable _pageY: number = 0; + @observable _indicatorX: number | undefined = undefined; + @observable _indicatorY: number | undefined = undefined; + + @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' | 'dashboard' = '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: 400, height: 400 }; + @observable _editing: boolean = false; + + constructor(props: any) { + super(props); + makeObservable(this); + DocCreatorMenu.Instance = this; + //setTimeout(() => this.generateTemplates('')); + } + + @action setDataViz = (dataViz: DataVizBox) => { + this._dataViz = dataViz; + }; + @action setTemplateDocs = (docs: Doc[]) => { + this._templateDocs = docs.map(doc => (doc.annotationOn ? DocCast(doc.annotationOn) : doc)); + }; + @action setGSuggestedTemplates = (docs: Doc[]) => { + this._suggestedTemplates = docs; + }; + + @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 selectedFields() { + return StrListCast(this._dataViz?.layoutDoc._dataViz_axes); + } + + @computed get fieldsInfos(): Col[] { + const colInfo = this._dataViz?.colsInfo; + return this.selectedFields + .map(field => { + const fieldInfo = colInfo?.get(field); + + const col: Col = { + title: field, + type: fieldInfo?.type ?? TemplateFieldType.UNSET, + desc: fieldInfo?.desc ?? '', + sizes: fieldInfo?.sizes ?? [TemplateFieldSize.MEDIUM], + }; + + if (fieldInfo?.defaultContent !== undefined) { + col.defaultContent = fieldInfo.defaultContent; + } + + return col; + }) + .concat(this._columns); + } + + @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(); + clickEv.preventDefault(); + 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; + } + }; + + componentDidMount() { + document.addEventListener('pointerdown', this.onPointerDown, true); + document.addEventListener('pointerup', this.onPointerUp); + this._disposers.templates = reaction( + () => this._templateDocs.slice(), + docs => docs.map(this.getIcon) + ); + this._disposers.gpt = reaction( + () => this._suggestedTemplates.slice(), + docs => docs.map(this.getIcon) + ); + //this._disposers.columns = reaction(() => this._dataViz?.layoutDoc._dataViz_axes, () => {this.generateTemplates('')}) + this._disposers.lightbox = reaction( + () => LightboxView.LightboxDoc(), + doc => { + doc ? this._shouldDisplay && this.closeMenu() : !this._shouldDisplay && this.openMenu(); + } + ); + //this._disposers.fields = reaction(() => this._dataViz?.axes, cols => this._selectedCols = cols?.map(col => { return {title: col, type: '', desc: ''}})) + } + + componentWillUnmount() { + Object.values(this._disposers).forEach(disposer => disposer?.()); + document.removeEventListener('pointerdown', this.onPointerDown, true); + document.removeEventListener('pointerup', this.onPointerUp); + } + + updateIcons = (docs: Doc[]) => { + docs.map(this.getIcon); + }; + + @action + updateSelectedCols = (cols: string[]) => { + this._selectedCols; + }; + + @action + toggleDisplay = (x: number, y: number) => { + if (this._shouldDisplay) { + this._shouldDisplay = false; + } else { + this._pageX = x; + this._pageY = y; + this._shouldDisplay = true; + } + }; + + @action + closeMenu = () => { + this._shouldDisplay = false; + }; + + @action + openMenu = () => { + const allTemplates = this._templateDocs.concat(this._suggestedTemplates); + this._shouldDisplay = true; + this.updateIcons(allTemplates); + }; + + @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(300, scale.x * width); + this._menuDimensions.height = Math.max(200, 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; + }; + + @action + generateTemplates = async (inputText: string) => { + ++this._callCount; + const origCount = this._callCount; + + let prompt: string = `(#${origCount}) Please generate for the fields:`; + this.selectedFields?.forEach(field => (prompt += ` ${field},`)); + prompt += ` (-----NOT A FIELD-----) Additional prompt: ${inputText}`; + + this._GPTLoading = true; + + try { + const res = await gptAPICall(prompt, GPTCallType.TEMPLATE); + + if (res && this._callCount === origCount) { + this._suggestedTemplates = []; + const templates: { template_type: string; fieldVals: { title: string; tlx: string; tly: string; brx: string; bry: string }[] }[] = JSON.parse(res); + this.createGeneratedTemplates(templates, 500, 500); + } + } catch (err) { + console.error(err); + } + }; + + @action + createGeneratedTemplates = (layouts: { template_type: string; fieldVals: { title: string; tlx: string; tly: string; brx: string; bry: string }[] }[], tempWidth: number, tempHeight: number) => { + const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; + const GPTTemplates: Doc[] = []; + + layouts.forEach(layout => { + const fields: Doc[] = layout.fieldVals.map(field => { + const left: number = (Number(field.tlx) * tempWidth) / 2; + const top: number = Number(field.tly) * tempHeight / 2; //prettier-ignore + const right: number = (Number(field.brx) * tempWidth) / 2; + const bottom: number = Number(field.bry) * tempHeight / 2; //prettier-ignore + const height = bottom - top; + const width = right - left; + const doc = !field.title.includes('$$') + ? Docs.Create.TextDocument('', { _height: height, _width: width, title: field.title, x: left, y: top, _text_fontSize: `${height / 2}` }) + : Docs.Create.ImageDocument('', { _height: height, _width: width, title: field.title.replace(/\$\$/g, ''), x: left, y: top }); + return doc; + }); + + const template = Docs.Create.FreeformDocument(fields, { _height: tempHeight, _width: tempWidth, title: layout.template_type, x: 400000, y: 400000 }); + + mainCollection.addDocument(template); + + GPTTemplates.push(template); + }); + + setTimeout(() => { + this.setGSuggestedTemplates(GPTTemplates); /*GPTTemplates.forEach(template => mainCollection.removeDocument(template))*/ + }, 100); + + this.forceUpdate(); + }; + + editTemplate = (doc: Doc) => { + //this.closeMenu(); + DocumentViewInternal.addDocTabFunc(doc, OpenWhere.addRight); + DocumentView.DeselectAll(); + Doc.UnBrushDoc(doc); + }; + + removeTemplate = (doc: Doc) => { + this._templateDocs.splice(this._templateDocs.indexOf(doc), 1); + }; + + testTemplate = async () => { + // const temp = TemplateLayouts.FourField001; + // const title: Doc = FieldFuncs.TextField({tl: temp.fields[0].tl, br: temp.fields[0].br}, temp.height, temp.width, 'title', 'Title', {backgroundColor: 'transparent'}); + // const image: Doc = FieldFuncs.ImageField({tl: temp.fields[1].tl, br: temp.fields[1].br}, temp.height, temp.width, 'title', '', {borderColor: '#159fe4', borderWidth: '10', cornerRounding: 10, rotation: 40}); + // const caption: Doc = FieldFuncs.TextField({tl: temp.fields[2].tl, br: temp.fields[2].br}, temp.height, temp.width, 'title', 'Caption', {backgroundColor: 'transparent'}); + // const desc: Doc = FieldFuncs.TextField({tl: temp.fields[3].tl, br: temp.fields[3].br}, temp.height, temp.width, 'title', '', {backgroundColor: 'lightblue', borderColor: '#159fe4', borderWidth: '10', cornerRounding: 10}); + + // const doc = Docs.Create.FreeformDocument([title, image, caption, desc], { _height: temp.height, _width: temp.width, title: 'hey', x: 400, y: 400 }); + + // const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; + // mainCollection.addDocument(doc); + + // const temp = TemplateLayouts.FourField001; + + // const img: Col = {type: TemplateFieldType.TEXT, title: 'Type', desc: 'description whpoo', size: TemplateFieldSize.LARGE, defaultContent: ''}; + // const capt1: Col = {type: TemplateFieldType.TEXT, title: 'Image', desc: 'description hey', size: TemplateFieldSize.TINY}; + // const capt2: Col = {type: TemplateFieldType.TEXT, title: 'Locality', desc: '', size: TemplateFieldSize.TINY, defaultContent: ''}; + // const desc: Col = {type: TemplateFieldType.TEXT, title: 'Description', desc: '', size: TemplateFieldSize.LARGE, defaultContent: 'This is a description of a rock. It is kind of long. It is very long. It is gratuitous. This description should be shorter. Oh well. This is a description of a rock. It is kind of long. It is very long. It is gratuitous. This description should be shorter. Oh well. This is a description of a rock. It is kind of long. It is very long. It is gratuitous. This description should be shorter. Oh well.'}; + + // const assignments = {'0': img, '1': capt1, '2': capt2, '3': desc} + + // this.createEmptyTemplate(temp, assignments); + // console.log(this.findValidTemplates(this.fieldsInfos, TemplateLayouts.allTemplates)); + + // console.log(this._dataViz?.colsInfo.get("IMG")?.size, this._dataViz?.colsInfo.get("IMG")?.type) + // console.log(this.fieldsInfos) + + try { + const res = await gptImageCall('Image of panda eating a cookie'); + + if (res) { + const result = await Networking.PostToServer('/uploadRemoteImage', { sources: res }); + + console.log(result); + } + } catch (e) { + console.log(e); + } + }; + + @action addField = () => { + const newFields: Col[] = this._columns.concat([{ title: '', type: TemplateFieldType.UNSET, desc: '', sizes: [] }]); + this._columns = newFields; + }; + + @action removeField = (field: { title: string; type: string; desc: string }) => { + if (this._dataViz?.axes.includes(field.title)) { + this._dataViz.selectAxes(this._dataViz.axes.filter(col => col !== field.title)); + } else { + const toRemove = this._columns.filter(f => f === field); + if (!toRemove) return; + + if (toRemove.length > 1) { + while (toRemove.length > 1) { + toRemove.pop(); + } + } + + if (this._columns.length === 1) { + this._columns = []; + } else { + this._columns.splice(this._columns.indexOf(toRemove[0]), 1); + } + } + }; + + @action setColTitle = (column: Col, title: string) => { + if (this.selectedFields.includes(column.title)) { + this._dataViz?.setColumnTitle(column.title, title); + } else { + column.title = title; + } + this.forceUpdate(); + }; + + @action setColType = (column: Col, type: TemplateFieldType) => { + if (this.selectedFields.includes(column.title)) { + this._dataViz?.setColumnType(column.title, type); + } else { + column.type = type; + } + this.forceUpdate(); + }; + + modifyColSizes = (column: Col, size: TemplateFieldSize, valid: boolean) => { + if (this.selectedFields.includes(column.title)) { + this._dataViz?.modifyColumnSizes(column.title, size, valid); + } else { + if (!valid && column.sizes.includes(size)) { + column.sizes.splice(column.sizes.indexOf(size), 1); + } else if (valid && !column.sizes.includes(size)) { + column.sizes.push(size); + } + } + this.forceUpdate(); + }; + + setColDesc = (column: Col, desc: string) => { + if (this.selectedFields.includes(column.title)) { + this._dataViz?.setColumnDesc(column.title, desc); + } else { + column.desc = desc; + } + this.forceUpdate(); + }; + + generateGPTImage = async (prompt: string): Promise<string | undefined> => { + console.log(prompt); + + try { + const res = await gptImageCall(prompt); + + if (res) { + const result = await Networking.PostToServer('/uploadRemoteImage', { sources: res }); + const source = ClientUtils.prepend(result[0].accessPaths.agnostic.client); + return source; + } + } catch (e) { + console.log(e); + } + }; + + matchesForTemplate = (template: TemplateDocInfos, cols: Col[]): number[][] => { + const colMatchesField = (col: Col, field: Field) => { + return field.sizes?.some(size => col.sizes?.includes(size)) && field.types?.includes(col.type); + }; + + const matches: number[][] = Array(template.fields.length) + .fill([]) + .map(() => []); + + template.fields.forEach((field, i) => { + cols.forEach((col, v) => { + if (colMatchesField(col, field)) { + matches[i].push(v); + } + }); + }); + + return matches; + }; + + maxMatches = (fieldsCt: number, matches: number[][]) => { + const used: boolean[] = Array(fieldsCt).fill(false); + const mt: number[] = Array(fieldsCt).fill(-1); + + const augmentingPath = (v: number): boolean => { + if (used[v]) return false; + used[v] = true; + for (const to of matches[v]) { + if (mt[to] === -1 || augmentingPath(mt[to])) { + mt[to] = v; + return true; + } + } + return false; + }; + + for (let v = 0; v < fieldsCt; ++v) { + used.fill(false); + augmentingPath(v); + } + + let count: number = 0; + + for (let i = 0; i < fieldsCt; ++i) { + if (mt[i] !== -1) ++count; + } + + return count; + }; + + findValidTemplates = (cols: Col[], templates: TemplateDocInfos[]) => { + let validTemplates: any[] = []; + templates.forEach(template => { + const numFields = template.fields.length; + if (!(numFields === cols.length)) return; + const matches = this.matchesForTemplate(template, cols); + if (this.maxMatches(numFields, matches) === numFields) { + validTemplates.push(template.title); + } + }); + + validTemplates = validTemplates.map(title => TemplateLayouts.getTemplateByTitle(title)); + + return validTemplates; + }; + + // createColumnField = (template: TemplateDocInfos, field: Field, column: Col): Doc => { + + // if (field.subfields) { + // const doc = FieldFuncs.FreeformField({ + // tl: field.tl, + // br: field.br }, + // template.height, + // template.width, + // column.title, + // '', + // field.opts + // ); + + // field.subfields[1].forEach(f => { + // const fDoc = () + // }) + + // } + + // return new Doc; + // } + + /** + * Populates a preset template framework with content from a datavizbox or any AI-generated content. + * @param template the preloaded template framework being filled in + * @param assignments a list of template field numbers (from top to bottom) and their assigned columns from the linked dataviz + * @returns a doc containing the fully rendered template + */ + fillPresetTemplate = async (template: TemplateDocInfos, assignments: { [field: string]: Col }): Promise<Doc> => { + const wordLimit = (size: TemplateFieldSize) => { + switch (size) { + case TemplateFieldSize.TINY: + return 2; + case TemplateFieldSize.SMALL: + return 5; + case TemplateFieldSize.MEDIUM: + return 20; + case TemplateFieldSize.LARGE: + return 50; + case TemplateFieldSize.HUGE: + return 100; + default: + return 10; + } + }; + + const renderTextCalls = async (): Promise<Doc[]> => { + const rendered: Doc[] = []; + + if (GPTTextCalls.length) { + try { + const prompt = fieldContent + GPTTextAssignment; + + const res = await gptAPICall(prompt, GPTCallType.FILL); + + if (res) { + const assignments: { [title: string]: { number: string; content: string } } = JSON.parse(res); + //console.log('assignments', GPTAssignments, 'assignment string', GPTAssignmentString, 'field content', fieldContent, 'response', res, 'assignments', assignments); + Object.entries(assignments).forEach(([title, info]) => { + const field: Field = template.fields[Number(info.number)]; + const col = this.getColByTitle(title); + + const doc = FieldUtils.TextField( + { + tl: field.tl, + br: field.br, + }, + template.height, + template.width, + col.title, + info.content ?? '', + field.opts + ); + + rendered.push(doc); + }); + } + } catch (err) { + console.log(err); + } + } + + return rendered; + }; + + const createGeneratedImage = async (fieldNum: string, col: Col, prompt: string) => { + const url = await this.generateGPTImage(prompt); + const field: Field = template.fields[Number(fieldNum)]; + const doc = FieldUtils.ImageField( + { + tl: field.tl, + br: field.br, + }, + template.height, + template.width, + col.title, + url ?? '', + field.opts + ); + + return doc; + }; + + const renderImageCalls = async (): Promise<Doc[]> => { + const rendered: Doc[] = []; + const calls = GPTIMGCalls; + + if (calls.length) { + try { + const renderedImages: Doc[] = await Promise.all( + calls.map(async ([fieldNum, col]) => { + const sysPrompt = + 'Your job is to create a prompt for an AI image generator to help it generate an image based on existing content in a template and a user prompt. Your prompt should focus heavily on visual elements to help the image generator; avoid unecessary info that might distract it. ONLY INCLUDE THE PROMPT, NO OTHER TEXT OR EXPLANATION. The existing content is as follows: ' + + fieldContent + + ' **** The user prompt is: ' + + col.desc; + + const prompt = await gptAPICall(sysPrompt, GPTCallType.COMPLETEPROMPT); + console.log(sysPrompt, prompt); + + return createGeneratedImage(fieldNum, col, prompt); + }) + ); + + const renderedTemplates: Doc[] = await Promise.all(renderedImages); + renderedTemplates.forEach(doc => rendered.push(doc)); + } catch (e) { + console.log(e); + } + } + + return rendered; + }; + + const fields: Doc[] = []; + + const GPTAssignments = Object.entries(assignments).filter(([f, col]) => this._columns.includes(col)); + const nonGPTAssignments: [string, Col][] = Object.entries(assignments).filter(a => !GPTAssignments.includes(a)); + const GPTTextCalls = GPTAssignments.filter(([str, col]) => col.type === TemplateFieldType.TEXT); + const GPTIMGCalls = GPTAssignments.filter(([str, col]) => col.type === TemplateFieldType.VISUAL); + + const stringifyGPTInfo = (calls: [string, Col][]): string => { + let string: string = '*** COLUMN INFO:'; + calls.forEach(([fieldNum, col]) => { + string += `--- title: ${col.title}, prompt: ${col.desc}, word limit: ${wordLimit(col.sizes[0])} words, assigned field: ${fieldNum} ---`; + }); + return (string += ' ***'); + }; + + const GPTTextAssignment = stringifyGPTInfo(GPTTextCalls); + + let fieldContent: string = ''; + + Object.entries(nonGPTAssignments).forEach(([f, strCol]) => { + const field: Field = template.fields[Number(f)]; + const col = strCol[1]; + + const doc = (col.type === TemplateFieldType.VISUAL ? FieldUtils.ImageField : FieldUtils.TextField)( + { + tl: field.tl, + br: field.br, + }, + template.height, + template.width, + col.title, + col.defaultContent ?? '', + field.opts + ); + + fieldContent += `--- Field #${f} (title: ${col.title}): ${col.defaultContent ?? ''} ---`; + + fields.push(doc); + }); + + template.decorations.forEach(dec => { + const doc = FieldUtils.FreeformField( + { + tl: dec.tl, + br: dec.br, + }, + template.height, + template.width, + '', + '', + dec.opts + ); + + fields.push(doc); + }); + + const createMainDoc = (): Doc => { + const main = Docs.Create.FreeformDocument(fields, { + _height: template.height, + _width: template.width, + title: template.title, + backgroundColor: template.opts.backgroundColor, + _layout_borderRounding: `${template.opts.cornerRounding}px` ?? '0px', + borderWidth: template.opts.borderWidth, + borderColor: template.opts.borderColor, + x: 40000, + y: 40000, + }); + + const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; + mainCollection.addDocument(main); + + return main; + }; + + const textCalls = await renderTextCalls(); + const imageCalls = await renderImageCalls(); + + textCalls.forEach(doc => { + fields.push(doc); + }); + imageCalls.forEach(doc => { + fields.push(doc); + }); + + return createMainDoc(); + }; + + compileFieldDescriptions = (templates: TemplateDocInfos[]): string => { + let descriptions: string = ''; + templates.forEach(template => { + descriptions += `---------- NEW TEMPLATE TO INCLUDE: Description of template ${template.title}'s fields: `; + template.fields.forEach((field, index) => { + descriptions += `{Field #${index}: ${field.description}} `; + }); + }); + + return descriptions; + }; + + compileColDescriptions = (cols: Col[]): string => { + let descriptions: string = ' ------------- COL DESCRIPTIONS START HERE:'; + cols.forEach(col => (descriptions += `{title: ${col.title}, sizes: ${String(col.sizes)}, type: ${col.type}, descreiption: ${col.desc}} `)); + + return descriptions; + }; + + getColByTitle = (title: string) => { + return this.fieldsInfos.filter(col => col.title === title)[0]; + }; + + @action + assignColsToFields = async (templates: TemplateDocInfos[], cols: Col[]): Promise<[TemplateDocInfos, { [field: number]: Col }][]> => { + const fieldDescriptions: string = this.compileFieldDescriptions(templates); + const colDescriptions: string = this.compileColDescriptions(cols); + + const inputText = fieldDescriptions.concat(colDescriptions); + + ++this._callCount; + const origCount = this._callCount; + + let prompt: string = `(${origCount}) ${inputText}`; + + this._GPTLoading = true; + + try { + const res = await gptAPICall(prompt, GPTCallType.TEMPLATE); + + if (res && this._callCount === origCount) { + const assignments: { [templateTitle: string]: { [field: string]: string } } = JSON.parse(res); + const brokenDownAssignments: [TemplateDocInfos, { [field: number]: Col }][] = []; + + Object.entries(assignments).forEach(([tempTitle, assignment]) => { + const template = TemplateLayouts.getTemplateByTitle(tempTitle); + if (!template) return; + const toObj = Object.entries(assignment).reduce( + (a, [fieldNum, colTitle]) => { + a[Number(fieldNum)] = this.getColByTitle(colTitle); + return a; + }, + {} as { [field: number]: Col } + ); + brokenDownAssignments.push([template, toObj]); + }); + return brokenDownAssignments; + } + } catch (err) { + console.error(err); + } + + return []; + }; + + generatePresetTemplates = async () => { + this._dataViz?.updateColDefaults(); + + const cols = this.fieldsInfos; + const templates = this.findValidTemplates(cols, TemplateLayouts.allTemplates); + + const assignments: [TemplateDocInfos, { [field: number]: Col }][] = await this.assignColsToFields(templates, cols); + + const renderedTemplatePromises: Promise<Doc>[] = assignments.map(([template, assignments]) => this.fillPresetTemplate(template, assignments)); + + const renderedTemplates: Doc[] = await Promise.all(renderedTemplatePromises); + + setTimeout(() => { + this.setGSuggestedTemplates(renderedTemplates); + this._GPTLoading = false; + }); + }; + + @action setExpandedView = (info: { icon: ImageField; doc: Doc } | undefined) => { + this._expandedPreview = info; + }; + + get templatesPreviewContents() { + const renderedTemplates: Doc[] = []; + + const GPTOptions = <div></div>; + + //<img className='docCreatorMenu-preview-image expanded' src={this._expandedPreview.icon!.url.href.replace(".png", "_o.png")} /> + + return ( + <div className={`docCreatorMenu-templates-view`}> + {this._expandedPreview ? ( + <div className="docCreatorMenu-expanded-template-preview"> + <img className="docCreatorMenu-preview-image expanded" src={this._expandedPreview.icon!.url.href.replace('.png', '_o.png')} /> + <div className="right-buttons-panel"> + <button className="docCreatorMenu-menu-button section-reveal-options top-right" onPointerDown={e => this.setUpButtonClick(e, () => this.setExpandedView(undefined))}> + <FontAwesomeIcon icon="minimize" /> + </button> + <button className="docCreatorMenu-menu-button section-reveal-options top-right-lower" onPointerDown={e => this.setUpButtonClick(e, () => this._expandedPreview && this._templateDocs.push(this._expandedPreview.doc))}> + <FontAwesomeIcon icon="plus" color="white" /> + </button> + </div> + </div> + ) : ( + <div> + <div className="docCreatorMenu-section" style={{ height: this._GPTOpt ? 200 : 200 }}> + <div className="docCreatorMenu-section-topbar"> + <div className="docCreatorMenu-section-title">Suggested Templates</div> + <button className="docCreatorMenu-menu-button section-reveal-options" onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => (this._menuContent = 'dashboard')))}> + <FontAwesomeIcon icon="gear" /> + </button> + </div> + <div className="docCreatorMenu-templates-preview-window" style={{ justifyContent: this._GPTLoading || this._menuDimensions.width > 400 ? 'center' : '' }}> + {this._GPTLoading ? ( + <div className="loading-spinner"> + <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> + </div> + ) : ( + this._suggestedTemplates + ?.map(doc => ({ icon: ImageCast(doc.icon), doc })) + .filter(info => info.icon && info.doc) + .map(info => ( + <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)))}> + <button + className="option-button left" + onPointerDown={e => + this.setUpButtonClick(e, () => { + this.setExpandedView(info); + }) + }> + <FontAwesomeIcon icon="magnifying-glass" color="white" /> + </button> + <button className="option-button right" onPointerDown={e => this.setUpButtonClick(e, () => this._templateDocs.push(info.doc))}> + <FontAwesomeIcon icon="plus" color="white" /> + </button> + <img className="docCreatorMenu-preview-image" src={info.icon!.url.href.replace('.png', '_o.png')} /> + </div> + )) + )} + </div> + <div className="docCreatorMenu-GPT-options"> + <div className="docCreatorMenu-GPT-options-container"> + <button className="docCreatorMenu-menu-button" onPointerDown={e => this.setUpButtonClick(e, () => this.generatePresetTemplates())}> + <FontAwesomeIcon icon="arrows-rotate" /> + </button> + </div> + {this._GPTOpt ? GPTOptions : null} + </div> + </div> + <hr className="docCreatorMenu-option-divider full no-margin" /> + <div className="docCreatorMenu-section"> + <div className="docCreatorMenu-section-topbar"> + <div className="docCreatorMenu-section-title">Your Templates</div> + <button className="docCreatorMenu-menu-button section-reveal-options" onPointerDown={e => this.setUpButtonClick(e, () => (this._GPTOpt = !this._GPTOpt))}> + <FontAwesomeIcon icon="gear" /> + </button> + </div> + <div className="docCreatorMenu-templates-preview-window" style={{ justifyContent: this._menuDimensions.width > 400 ? 'center' : '' }}> + <div className="docCreatorMenu-preview-window empty" onPointerDown={e => this.testTemplate()}> + <FontAwesomeIcon icon="plus" color="rgb(160, 160, 160)" /> + </div> + {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)))}> + <button + className="option-button left" + onPointerDown={e => + this.setUpButtonClick(e, () => { + this.editTemplate(info.doc); + }) + }> + <FontAwesomeIcon icon="pencil" color="black" /> + </button> + <button + className="option-button right" + onPointerDown={e => + this.setUpButtonClick(e, () => { + this.removeTemplate(info.doc); + }) + }> + <FontAwesomeIcon icon="trash" color="black" /> + </button> + <img className="docCreatorMenu-preview-image" src={info.icon!.url.href.replace('.png', '_o.png')} /> + </div> + ); + })} + </div> + </div> + </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; + } + } + + // doc = () => { + // return Docs.Create.FreeformDocument([], { _height: 200, _width: 200, title: 'title'}); + // } + + screenToLocalTransform = () => this._props.ScreenToLocalTransform(); + + 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='divvv' style={{width: 100, height: 100, border: `1px solid white`}}> + // <CollectionFreeFormView + // // eslint-disable-next-line react/jsx-props-no-spreading + // {...this._props} + // Document={new Doc()} + // isContentActive={returnFalse} + // setContentViewBox={emptyFunction} + // NativeWidth={() => 100} + // NativeHeight={() => 100} + // pointerEvents={SnappingManager.IsDragging ? returnAll : returnNone} + // isAnnotationOverlay + // isAnnotationOverlayScrollable + // childDocumentsActive={returnFalse} + // fieldKey={this._props.fieldKey + '_annotations'} + // dropAction={dropActionType.move} + // select={emptyFunction} + // addDocument={returnFalse} + // removeDocument={returnFalse} + // moveDocument={returnFalse} + // renderDepth={this._props.renderDepth + 1}> + // {null} + // </CollectionFreeFormView> + // </div> + <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 *= 0.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 * 0.4, height: height }}> + <FontAwesomeIcon icon={icon as any} /> + </div> + {manual ? ( + <input className={`docCreatorMenu-input config ${specClass}`} style={{ width: width * 0.6, height: height }} /> + ) : ( + <select className={`docCreatorMenu-input config ${specClass}`} style={{ width: width * 0.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(this._menuDimensions.width * 0.75) : 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 dashboardContents() { + const sizes: string[] = ['tiny', 'small', 'medium', 'large', 'huge']; + + const fieldPanel = (field: Col) => { + return ( + <div className="field-panel"> + <div className="top-bar"> + <span className="field-title">{`${field.title} Field`}</span> + <button className="docCreatorMenu-menu-button section-reveal-options no-margin" onPointerDown={e => this.setUpButtonClick(e, this.addField)} style={{ position: 'absolute', right: '0px' }}> + <FontAwesomeIcon icon="minus" /> + </button> + </div> + <div className="opts-bar"> + <div className="opt-box"> + <div className="top-bar"> Title </div> + <textarea className="content" style={{ width: '100%', height: 'calc(100% - 20px)' }} defaultValue={field.title} placeholder={'Enter title'} onChange={e => this.setColTitle(field, e.target.value)} /> + </div> + <div className="opt-box"> + <div className="top-bar"> Type </div> + <div className="content"> + <span className="type-display">{field.type === TemplateFieldType.TEXT ? 'Text Field' : field.type === TemplateFieldType.VISUAL ? 'File Field' : ''}</span> + <div className="bubbles"> + <input + className="bubble" + type="radio" + name="type" + onClick={() => { + this.setColType(field, TemplateFieldType.TEXT); + }} + /> + <div className="text">Text</div> + <input + className="bubble" + type="radio" + name="type" + onClick={() => { + this.setColType(field, TemplateFieldType.VISUAL); + }} + /> + <div className="text">File</div> + </div> + </div> + </div> + </div> + <div className="sizes-box"> + <div className="top-bar"> Valid Sizes </div> + <div className="content"> + <div className="bubbles"> + {sizes.map(size => ( + <> + <input + className="bubble" + type="checkbox" + name="type" + checked={field.sizes.includes(size as TemplateFieldSize)} + onChange={e => { + this.modifyColSizes(field, size as TemplateFieldSize, e.target.checked); + }} + /> + <div className="text">{size}</div> + </> + ))} + </div> + </div> + </div> + <div className="desc-box"> + <div className="top-bar"> Description </div> + <textarea + className="content" + onChange={e => this.setColDesc(field, e.target.value)} + defaultValue={field.desc === this._dataViz?.GPTSummary?.get(field.title)?.desc ? '' : field.desc} + placeholder={this._dataViz?.GPTSummary?.get(field.title)?.desc ?? 'Add a description to help with template generation.'} + /> + </div> + </div> + ); + }; + + return ( + <div className="docCreatorMenu-dashboard-view"> + <div className="topbar"> + <button className="docCreatorMenu-menu-button section-reveal-options" onPointerDown={e => this.setUpButtonClick(e, this.addField)}> + <FontAwesomeIcon icon="plus" /> + </button> + <button className="docCreatorMenu-menu-button section-reveal-options float-right" onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => (this._menuContent = 'templates')))}> + <FontAwesomeIcon icon="arrow-left" /> + </button> + </div> + <div className="panels-container">{this.fieldsInfos.map(field => fieldPanel(field))}</div> + </div> + ); + } + + get renderSelectedViewType() { + switch (this._menuContent) { + case 'templates': + return this.templatesPreviewContents; + case 'options': + return this.optionsMenuContents; + case 'saved': + return this.savedLayoutsPreviewContents; + case 'dashboard': + return this.dashboardContents; + 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 = 'dashboard'; + }; + 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-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; +} + +export enum TemplateFieldType { + TEXT = 'text', + VISUAL = 'visual', + UNSET = 'unset', +} + +export enum TemplateFieldSize { + TINY = 'tiny', + SMALL = 'small', + MEDIUM = 'medium', + LARGE = 'large', + HUGE = 'huge', +} + +export type Col = { + sizes: TemplateFieldSize[]; + desc: string; + title: string; + type: TemplateFieldType; + defaultContent?: string; +}; + +type Field = { + tl: [number, number]; + br: [number, number]; + opts: FieldOpts; + subfields?: Field[]; + types?: TemplateFieldType[]; + sizes?: TemplateFieldSize[]; + isDecoration?: boolean; + description?: string; +}; + +// class ContentField implements Field { +// tl: [number, number]; +// br: [number, number]; +// opts: FieldOpts; +// subfields?: Field[]; +// types?: TemplateFieldType[]; +// sizes?: TemplateFieldSize[]; +// description?: string; + +// constructor( tl: [number, number], br: [number, number], +// opts: FieldOpts, subfields?: Field[], +// types?: TemplateFieldType[], +// sizes?: TemplateFieldSize[], +// description?: string) { +// this.tl = tl; +// this.br = br; +// this.opts = opts; +// this.subfields = subfields; +// this.types = types; +// this.sizes = sizes; +// this.description = description; +// } + +// render = (content: any): Doc => { +// return new Doc; +// } +// } + +type DecorationField = Field; + +type InkDecoration = {}; + +type TemplateDecorations = Field | InkDecoration; + +export interface TemplateDocInfos { + title: string; + height: number; + width: number; + opts: TemplateOpts; + fields: Field[]; + decorations: Field[]; +} + +export interface FieldOpts { + backgroundColor?: string; + color?: string; + cornerRounding?: number; + borderWidth?: string; + borderColor?: string; + contentXCentering?: 'h-left' | 'h-center' | 'h-right'; + contentYCentering?: 'top' | 'center' | 'bottom'; + opacity?: number; + rotation?: number; + //animation?: boolean; + fontBold?: boolean; + fontTransform?: 'uppercase' | 'lowercase'; + fieldViewType?: 'freeform' | 'stacked'; +} + +interface TemplateOpts extends FieldOpts {} + +export class FieldUtils { + public static contentFields = (fields: Field[]) => { + let toRet: Field[] = []; + fields.forEach(field => { + if (!field.isDecoration) { + toRet.push(field); + } + toRet = toRet.concat(FieldUtils.contentFields(field.subfields ?? [])); + }); + + return toRet; + }; + + public static calculateFontSize = (contWidth: number, contHeight: number, text: string, uppercase: boolean): number => { + const words: string[] = text.split(/\s+/).filter(Boolean); + + let currFontSize = 1; + let rowsCount = 1; + let currTextHeight = currFontSize * rowsCount * 2; + + while (currTextHeight <= contHeight) { + let wordIndex = 0; + let currentRowWidth = 0; + let wordsInCurrRow = 0; + rowsCount = 1; + + while (wordIndex < words.length) { + const word = words[wordIndex]; + const wordWidth = word.length * currFontSize * 0.5; + //console.log(wordWidth) + + if (currentRowWidth + wordWidth <= contWidth) { + currentRowWidth += wordWidth; + ++wordsInCurrRow; + } else { + if (words.length !== 1 && words.length > wordsInCurrRow) { + rowsCount++; + currentRowWidth = wordWidth; + wordsInCurrRow = 1; + } else { + break; + } + } + + wordIndex++; + } + + currTextHeight = rowsCount * currFontSize * 2; + //console.log(rowsCount, currFontSize, currTextHeight) + + currFontSize += 1; + } + + return currFontSize - 1; + }; + + private static getDimensions = (coords: { tl: [number, number]; br: [number, number] }, parentWidth: number, parentHeight: number): { width: number; height: number; coord: { x: number; y: number } } => { + const l = (coords.tl[0] * parentHeight) / 2; + const t = coords.tl[1] * parentWidth / 2; //prettier-ignore + const r = (coords.br[0] * parentHeight) / 2; + const b = coords.br[1] * parentWidth / 2; //prettier-ignore + const width = r - l; + const height = b - t; + const coord = { x: l, y: t }; + //console.log(coords, parentWidth, parentHeight, height); + return { width, height, coord }; + }; + + public static FreeformField = (coords: { tl: [number, number]; br: [number, number] }, parentWidth: number, parentHeight: number, title: string, content: string, opts: FieldOpts) => { + const { width, height, coord } = FieldUtils.getDimensions(coords, parentWidth, parentHeight); + + const docWithBasicOpts = Docs.Create.FreeformDocument([], { + isDefaultTemplateDoc: true, + _height: height, + _width: width, + title: title, + x: coord.x, + y: coord.y, + backgroundColor: opts.backgroundColor ?? '', + _layout_borderRounding: `${opts.cornerRounding}px` ?? '0px', + borderColor: opts.borderColor, + borderWidth: opts.borderWidth, + opacity: opts.opacity, + hCentering: opts.contentXCentering, + _rotation: opts.rotation, + }); + + return docWithBasicOpts; + }; + + public static TextField = (coords: { tl: [number, number]; br: [number, number] }, parentWidth: number, parentHeight: number, title: string, content: string, opts: FieldOpts) => { + const { width, height, coord } = FieldUtils.getDimensions(coords, parentWidth, parentHeight); + + const bool = true; + + const docWithBasicOpts = Docs.Create.TextDocument(content, { + isDefaultTemplateDoc: true, + _height: height, + _width: width, + title: title, + x: coord.x, + y: coord.y, + _text_fontSize: `${FieldUtils.calculateFontSize(width, height, content, true)}`, + backgroundColor: opts.backgroundColor ?? '', + text_fontColor: opts.color, + contentBold: opts.fontBold, + textTransform: opts.fontTransform, + color: opts.color, + _layout_borderRounding: `${opts.cornerRounding}px` ?? '0px', + borderColor: opts.borderColor, + borderWidth: opts.borderWidth, + opacity: opts.opacity, + hCentering: opts.contentXCentering, + _rotation: opts.rotation, + }); + + docWithBasicOpts._layout_hideScroll = true; + + return docWithBasicOpts; + }; + + public static ImageField = (coords: { tl: [number, number]; br: [number, number] }, parentWidth: number, parentHeight: number, title: string, content: string, opts: FieldOpts) => { + const { width, height, coord } = FieldUtils.getDimensions(coords, parentWidth, parentHeight); + + const doc = Docs.Create.ImageDocument(content, { + isDefaultTemplateDoc: true, + _height: height, + _width: width, + title: title, + x: coord.x, + y: coord.y, + _layout_fitWidth: false, + backgroundColor: opts.backgroundColor ?? '', + _layout_borderRounding: `${opts.cornerRounding}px` ?? '0px', + borderColor: opts.borderColor, + borderWidth: opts.borderWidth, + opacity: opts.opacity, + _rotation: opts.rotation, + }); + + //setTimeout(() => {doc._height = height; doc._width = width}, 10); + + return doc; + }; + + public static CarouselField = (coords: { tl: [number, number]; br: [number, number] }, parentWidth: number, parentHeight: number, title: string, fields: Doc[]) => { + const { width, height, coord } = FieldUtils.getDimensions(coords, parentWidth, parentHeight); + + const doc = Docs.Create.Carousel3DDocument(fields, { _height: height, _width: width, title: title, x: coord.x, y: coord.y, _text_fontSize: `${height / 2}` }); + + return doc; + }; +} + +export class TemplateLayouts { + public static get allTemplates(): TemplateDocInfos[] { + return Object.values(TemplateLayouts).filter(value => typeof value === 'object' && value !== null && 'title' in value) as TemplateDocInfos[]; + } + + public static getTemplateByTitle = (title: string): TemplateDocInfos | undefined => { + switch (title) { + case 'fourfield1': + return TemplateLayouts.FourField001; + case 'fourfield2': + return TemplateLayouts.FourField002; + // case 'fourfield3': + // return TemplateLayouts.FourField003; + case 'fourfield4': + return TemplateLayouts.FourField004; + case 'threefield1': + return TemplateLayouts.ThreeField001; + case 'threefield2': + return TemplateLayouts.ThreeField002; + default: + break; + } + + return undefined; + }; + + public static FourField001: TemplateDocInfos = { + title: 'fourfield1', + width: 416, + height: 700, + opts: { + backgroundColor: '#C0B887', + cornerRounding: 20, + borderColor: '#6B461F', + borderWidth: '12', + }, + fields: [ + { + tl: [-0.95, -1], + br: [0.95, -0.85], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY], + description: 'A title field for very short text that contextualizes the content.', + opts: { + backgroundColor: 'transparent', + color: '#F1F0E9', + contentXCentering: 'h-center', + fontBold: true, + }, + }, + { + tl: [-0.87, -0.83], + br: [0.87, 0.2], + types: [TemplateFieldType.TEXT, TemplateFieldType.VISUAL], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + description: 'The main focus of the template; could be an image, long text, etc.', + opts: { + cornerRounding: 20, + borderColor: '#8F5B25', + borderWidth: '6', + backgroundColor: '#CECAB9', + }, + }, + { + tl: [-0.8, 0.2], + br: [0.8, 0.3], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], + description: 'A caption for field #2, very short to short text that contextualizes the content of field #2', + opts: { + backgroundColor: 'transparent', + contentXCentering: 'h-center', + color: '#F1F0E9', + }, + }, + { + tl: [-0.87, 0.37], + br: [0.87, 0.96], + types: [TemplateFieldType.TEXT, TemplateFieldType.VISUAL], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + description: 'A medium-sized field for medium/long text.', + opts: { + cornerRounding: 15, + borderColor: '#8F5B25', + borderWidth: '6', + backgroundColor: '#CECAB9', + }, + }, + ], + decorations: [], + }; + + public static FourField002: TemplateDocInfos = { + title: 'fourfield2', + width: 425, + height: 778, + opts: { + backgroundColor: '#242425', + }, + fields: [ + { + tl: [-0.83, -0.95], + br: [0.83, -0.2], + types: [TemplateFieldType.VISUAL, TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], + description: 'A medium to large-sized field suitable for an image or longer text that should be the main focus.', + opts: { + borderWidth: '8', + borderColor: '#F8E71C', + }, + }, + { + tl: [-0.65, -0.2], + br: [0.65, -0.02], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY], + description: 'A tiny field for just a word or two of plain text.', + opts: { + backgroundColor: 'transparent', + color: 'white', + contentXCentering: 'h-center', + fontTransform: 'uppercase', + }, + }, + { + tl: [-0.65, 0], + br: [0.65, 0.18], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY], + description: 'A tiny field for just a word or two of plain text.', + opts: { + backgroundColor: 'transparent', + color: 'white', + contentXCentering: 'h-center', + fontTransform: 'uppercase', + }, + }, + { + tl: [-0.83, 0.2], + br: [0.83, 0.95], + types: [TemplateFieldType.TEXT, TemplateFieldType.VISUAL], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + description: 'A medium to large-sized field suitable for an image or longer text that should be the main focus, or share focus with field 1.', + opts: { + borderWidth: '8', + borderColor: '#F8E71C', + color: 'white', + backgroundColor: '#242425', + }, + }, + ], + decorations: [ + { + tl: [-0.8, -0.075], + br: [-0.525, 0.075], + opts: { + backgroundColor: '#F8E71C', + rotation: 45, + }, + }, + { + tl: [-0.3075, -0.0245], + br: [-0.2175, 0.0245], + opts: { + backgroundColor: '#F8E71C', + rotation: 45, + }, + }, + { + tl: [-0.045, -0.0245], + br: [0.045, 0.0245], + opts: { + backgroundColor: '#F8E71C', + rotation: 45, + }, + }, + { + tl: [0.2175, -0.0245], + br: [0.3075, 0.0245], + opts: { + backgroundColor: '#F8E71C', + rotation: 45, + }, + }, + { + tl: [0.525, -0.075], + br: [0.8, 0.075], + opts: { + backgroundColor: '#F8E71C', + rotation: 45, + }, + }, + ], + }; + + // public static FourField003: TemplateDocInfos = { + // title: 'fourfield3', + // width: 477, + // height: 662, + // opts: { + // backgroundColor: '#9E9C95' + // }, + // fields: [{ + // tl: [-.875, -.9], + // br: [.875, .7], + // types: [TemplateFieldType.VISUAL], + // sizes: [TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + // description: '', + // opts: { + // borderWidth: '15', + // borderColor: '#E0E0DA', + // } + // }, { + // tl: [-.95, .8], + // br: [-.1, .95], + // types: [TemplateFieldType.TEXT], + // sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], + // description: '', + // opts: { + // backgroundColor: 'transparent', + // color: 'white', + // contentXCentering: 'h-right', + // } + // }, { + // tl: [.1, .8], + // br: [.95, .95], + // types: [TemplateFieldType.TEXT], + // sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], + // description: '', + // opts: { + // backgroundColor: 'transparent', + // color: 'red', + // fontTransform: 'uppercase', + // contentXCentering: 'h-left' + // } + // }, { + // tl: [0, -.9], + // br: [.85, -.66], + // types: [TemplateFieldType.TEXT, TemplateFieldType.VISUAL], + // sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + // description: '', + // opts: { + // backgroundColor: 'transparent', + // contentXCentering: 'h-right' + // } + // }], + // decorations: [{ + // tl: [-.025, .8], + // br: [.025, .95], + // opts: { + // backgroundColor: '#E0E0DA', + // } + // }] + // }; + + public static FourField004: TemplateDocInfos = { + title: 'fourfield4', + width: 414, + height: 583, + opts: { + backgroundColor: '#6CCAF0', + borderColor: '#1088C3', + borderWidth: '10', + }, + fields: [ + { + tl: [-0.86, -0.92], + br: [-0.075, -0.77], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY], + description: 'A tiny field for just a word or two of plain text.', + opts: { + backgroundColor: '#E2B4F5', + borderWidth: '9', + borderColor: '#9222F1', + contentXCentering: 'h-center', + }, + }, + { + tl: [0.075, -0.92], + br: [0.86, -0.77], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY], + description: 'A tiny field for just a word or two of plain text.', + opts: { + backgroundColor: '#F5B4DD', + borderWidth: '9', + borderColor: '#E260F3', + contentXCentering: 'h-center', + }, + }, + { + tl: [-0.81, -0.64], + br: [0.81, 0.48], + types: [TemplateFieldType.VISUAL], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + description: 'A large to huge field for visual content that is the main content of the template.', + opts: { + borderWidth: '16', + borderColor: '#A2BD77', + }, + }, + { + tl: [-0.86, 0.6], + br: [0.86, 0.92], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], + description: 'A medium to large field for text that describes the visual content above', + opts: { + borderWidth: '9', + borderColor: '#F0D601', + backgroundColor: '#F3F57D', + }, + }, + ], + decorations: [ + { + tl: [-0.852, -0.67], + br: [0.852, 0.51], + opts: { + backgroundColor: 'transparent', + borderColor: '#007C0C', + borderWidth: '10', + }, + }, + ], + }; + + public static ThreeField001: TemplateDocInfos = { + title: 'threefield1', + width: 575, + height: 770, + opts: { + backgroundColor: '#DDD3A9', + }, + fields: [ + { + tl: [-0.66, -0.747], + br: [0.66, 0.247], + types: [TemplateFieldType.VISUAL], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + description: 'A medium to large field for visual content that is the central focus.', + opts: { + borderColor: 'yellow', + borderWidth: '8', + rotation: 45, + }, + }, + { + tl: [-0.7, 0.2], + br: [0.7, 0.46], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], + description: 'A very small text field for one to a few words. A good caption for the image.', + opts: { + backgroundColor: 'transparent', + contentXCentering: 'h-center', + }, + }, + { + tl: [-0.95, 0.5], + br: [0.95, 0.95], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], + description: 'A medium to large text field for a thorough description of the image. ', + opts: { + backgroundColor: 'transparent', + color: 'white', + }, + }, + ], + decorations: [ + { + tl: [0.2, -1.32], + br: [1.8, -0.66], + opts: { + backgroundColor: '#CEB155', + rotation: 45, + }, + }, + { + tl: [-1.8, -1.32], + br: [-0.2, -0.66], + opts: { + backgroundColor: '#CEB155', + rotation: 135, + }, + }, + { + tl: [0.33, 0.75], + br: [1.66, 1.25], + opts: { + backgroundColor: '#CEB155', + rotation: 135, + }, + }, + { + tl: [-1.66, 0.75], + br: [-0.33, 1.25], + opts: { + backgroundColor: '#CEB155', + rotation: 45, + }, + }, + ], + }; + + public static ThreeField002: TemplateDocInfos = { + title: 'threefield2', + width: 477, + height: 662, + opts: { + backgroundColor: '#9E9C95', + }, + fields: [ + { + tl: [-0.875, -0.9], + br: [0.875, 0.7], + types: [TemplateFieldType.VISUAL], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + description: 'A medium to large visual field for the main content of the template', + opts: { + borderWidth: '15', + borderColor: '#E0E0DA', + }, + }, + { + tl: [0.1, 0.775], + br: [0.95, 0.975], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], + description: 'A very small text field for one to a few words. The content should represent a general categorization of the image.', + opts: { + backgroundColor: 'transparent', + color: '#AF0D0D', + fontTransform: 'uppercase', + fontBold: true, + contentXCentering: 'h-left', + }, + }, + { + tl: [-0.95, 0.775], + br: [-0.1, 0.975], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], + description: 'A very small text field for one to a few words. The content should contextualize field 2.', + opts: { + backgroundColor: 'transparent', + color: 'black', + contentXCentering: 'h-right', + }, + }, + ], + decorations: [ + { + tl: [-0.025, 0.8], + br: [0.025, 0.95], + opts: { + backgroundColor: '#E0E0DA', + }, + }, + ], + }; + + // public static FourField002: TemplateDocInfos = { + // width: 450, + // height: 600, + // fields: [{ + // tl: [-.6, -.9], + // br: [.6, -.8], + // types: [FieldType.TEXT], + // sizes: [FieldSize.TINY] + // }, { + // tl: [-.9, -.7], + // br: [.9, .2], + // types: [FieldType.TEXT, FieldType.VISUAL], + // sizes: [FieldSize.MEDIUM, FieldSize.LARGE, FieldSize.HUGE] + // }, { + // tl: [-.9, .3], + // br: [-.05, .9], + // types: [FieldType.TEXT], + // sizes: [FieldSize.TINY] + // }, { + // tl: [.05, .3], + // br: [.9, .9], + // types: [FieldType.TEXT, FieldType.VISUAL], + // sizes: [FieldSize.MEDIUM, FieldSize.LARGE, FieldSize.HUGE] + // }] + // }; + + // public static TwoFieldPlusCarousel: TemplateDocInfos = { + // width: 500, + // height: 600, + // fields: [{ + // tl: [-.9, -.99], + // br: [.9, -.7], + // types: [FieldType.TEXT], + // sizes: [FieldSize.TINY] + // }, { + // tl: [-.9, -.65], + // br: [.9, .35], + // types: [], + // sizes: [] + // }, { + // tl: [-.9, .4], + // br: [.9, .95], + // types: [FieldType.TEXT], + // sizes: [FieldSize.TINY] + // }] + // }; +} + +// export class ContentField extends Field { + +// } diff --git a/src/client/views/nodes/DataVizBox/TemplateDocTypes.tsx b/src/client/views/nodes/DataVizBox/TemplateDocTypes.tsx new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/client/views/nodes/DataVizBox/TemplateDocTypes.tsx diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index e57c9e842..6cc773da0 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -1,4 +1,6 @@ -import { Button, Type } from 'browndash-components'; +/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +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'; @@ -35,6 +37,7 @@ interface TableBoxProps { left: number; }; docView?: () => DocumentView | undefined; + specHighlightedRow: number | undefined; } @observer @@ -178,7 +181,6 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { } else { const newAxes = this._props.axes; if (newAxes.includes(col)) newAxes.splice(newAxes.indexOf(col), 1); - else if (newAxes.length > 2) newAxes[newAxes.length - 1] = col; else newAxes.push(col); this._props.selectAxes(newAxes); } @@ -410,11 +412,13 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { color: this._props.axes.slice().reverse().lastElement() === col ? 'darkgreen' - : this._props.axes.length > 2 && this._props.axes.lastElement() === col + : this._props.axes.length > 3 && this._props.axes.lastElement() === col ? 'darkred' - : this._props.axes.lastElement() === col || (this._props.axes.length > 2 && this._props.axes[1] === col) + : this._props.axes.length > 3 && this._props.axes[1] === col ? 'darkblue' - : undefined, + : this._props.axes.lastElement() === col || (this._props.axes.length > 3 && this._props.axes[2] === col) + ? 'darkcyan' + : undefined, background: this.settingTitle ? 'lightgrey' : this._props.axes.slice().reverse().lastElement() === col @@ -423,7 +427,9 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { ? '#Fbdbdb' : this._props.axes.lastElement() === col || (this._props.axes.length > 2 && this._props.axes[1] === col) ? '#c6ebf7' - : undefined, + : this._props.axes.lastElement() === col || (this._props.axes.length > 3 && this._props.axes[2] === col) + ? '#c2f0f4' + : undefined, fontWeight: 'bolder', border: '3px solid black', }} @@ -442,7 +448,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.scss b/src/client/views/nodes/DocumentView.scss index 23dada260..7568e3b57 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -240,6 +240,12 @@ } } +.contentFittingDocumentView * { + ::-webkit-scrollbar-track { + background: none; + } +} + .contentFittingDocumentView { position: relative; display: flex; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index d730e661b..4a249838b 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1075,6 +1075,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>; @@ -1160,7 +1161,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { return Math.max(minTextScale, this._props.PanelHeight() / (this.effectiveNativeHeight || 1)); // height-limited or unscaled } @computed private get panelWidth() { - return this.effectiveNativeWidth ? this.effectiveNativeWidth * this.nativeScaling : this._props.PanelWidth(); + return this.effectiveNativeWidth ? this.effectiveNativeWidth * this.nativeScaling: this._props.PanelWidth(); } @computed private get panelHeight() { if (this.effectiveNativeHeight && (!this.layout_fitWidth || !this.layoutDoc.layout_reflowVertical)) { @@ -1471,6 +1472,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { render() { TraceMobx(); + const borderWidth = 50/*Number(StrCast(this.layoutDoc.layout_borderWidth).replace('px', ''))*/; const xshift = Math.abs(this.Xshift) <= 0.001 ? this._props.PanelWidth() : undefined; const yshift = Math.abs(this.Yshift) <= 0.001 ? this._props.PanelHeight() : undefined; diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index 683edba16..a0d69d29d 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -139,7 +139,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 ec5e062c8..226fad977 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -124,15 +124,15 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { { fireImmediately: true, delay: 1000 } ); const { layoutDoc } = this; - this._disposers.path = reaction( - () => ({ nativeSize: this.nativeSize, width: NumCast(this.layoutDoc._width) }), - ({ nativeSize, width }) => { - if (layoutDoc === this.layoutDoc || !this.layoutDoc._height) { - this.layoutDoc._height = (width * nativeSize.nativeHeight) / nativeSize.nativeWidth; - } - }, - { fireImmediately: true } - ); + // this._disposers.path = reaction( + // () => ({ nativeSize: this.nativeSize, width: NumCast(this.layoutDoc._width) }), + // ({ nativeSize, width }) => { + // if (layoutDoc === this.layoutDoc || !this.layoutDoc._height) { + // this.layoutDoc._height = (width * nativeSize.nativeHeight) / nativeSize.nativeWidth; + // } + // }, + // { fireImmediately: true } + // ); this._disposers.scroll = reaction( () => this.layoutDoc.layout_scrollTop, sTop => { @@ -306,6 +306,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @computed get nativeSize() { TraceMobx(); + if (this.paths.length && this.paths[0].includes('icon-hi')) return { nativeWidth: NumCast(this.layoutDoc._width), nativeHeight: NumCast(this.layoutDoc._height), nativeOrientation: 0} const nativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth'], 500)); const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '_nativeHeight'], 500)); const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '_nativeOrientation'], 1); @@ -352,7 +353,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 = @@ -405,7 +406,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { alt="" key="paths" src={srcpath} - style={{ transform, transformOrigin }} + style={{ transform, transformOrigin, objectFit: 'fill', height: '100%' }} onError={action(e => { this._error = e.toString(); })} @@ -486,7 +487,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { })} style={{ width: this._props.PanelWidth() ? undefined : `100%`, - height: this._props.PanelWidth() ? undefined : `100%`, + height: this._props.PanelHeight() ? undefined : `100%`, pointerEvents: this.layoutDoc._lockedPosition ? 'none' : undefined, borderRadius, overflow: this.layoutDoc.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : undefined, diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 8974cccaf..cfcf76b12 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -2,7 +2,7 @@ import { Property } from 'csstype'; import { action, computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import * as textfit from 'textfit'; +//import * as textfit from 'textfit'; import { Field, FieldType } from '../../../fields/Doc'; import { BoolCast, NumCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; @@ -96,7 +96,7 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { this._timeout = setTimeout(() => this.fitTextToBox(r)); return textfitParams; } - textfit(r, textfitParams); + //textfit(r, textfitParams); } return textfitParams; }; diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index 99b4a84fc..72d550c7e 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -14,6 +14,33 @@ } } +.formattedTextBox-inner { + &.h-center * { + display: flex; + justify-content: center; + } + + &.h-left * { + display: flex; + justify-content: flex-start; + } + + &.h-right * { + display: flex; + justify-content: flex-end; + } + + &.template * { + ::-webkit-scrollbar-track { + background: none; + } + } + + &.bold * { + font-weight: bold; + } +} + .ProseMirror:focus { outline: none !important; } @@ -52,6 +79,7 @@ audiotag:hover { transform-origin: left top; top: 0; left: 0; + } .formattedTextBox-cont { @@ -1035,3 +1063,4 @@ footnote::before { } } } + diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index d3bc08bd3..730c57794 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -64,6 +64,7 @@ import { removeMarkWithAttrs } from './prosemirrorPatches'; import { RichTextMenu, RichTextMenuPlugin } from './RichTextMenu'; import { RichTextRules } from './RichTextRules'; import { schema } from './schema_rts'; +import { Property } from 'csstype'; // import * as applyDevTools from 'prosemirror-dev-tools'; export interface FormattedTextBoxProps extends FieldViewProps { @@ -1493,6 +1494,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB pdfAnchorId && this.addPdfReference(pdfAnchorId); } if (this._props.autoFocus) setTimeout(() => this._editorView!.focus()); // not sure why setTimeout is needed but editing dashFieldView's doesn't work without it. + } // add user mark for any first character that was typed since the user mark that gets set in KeyPress won't have been called yet. @@ -2059,7 +2061,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB onScroll={this.onScroll} onDrop={this.ondrop}> <div - className={`formattedTextBox-inner${rounded} ${this.layoutDoc._layout_centered ? 'centered' : ''}`} + className={`formattedTextBox-inner${rounded} ${this.layoutDoc._layout_centered ? 'centered' : ''} ${this.layoutDoc.hCentering}`} ref={this.createDropTarget} style={{ padding: StrCast(this.layoutDoc._textBoxPadding), @@ -2067,6 +2069,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB paddingRight: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX}px`), paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY}px`), paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY}px`), + color: StrCast(this.layoutDoc.text_fontColor), + fontWeight: `${this.layoutDoc.contentBold ? 'bold' : ''}`, + textTransform: `${this.layoutDoc.textTransform}` as Property.TextTransform, }} /> </div> @@ -2077,6 +2082,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB </div> </div> ); + } } |
