diff options
Diffstat (limited to 'src/client/views/nodes')
107 files changed, 5200 insertions, 3152 deletions
diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 933a383ea..c25c09af9 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -138,7 +138,7 @@ input[type='range']::-webkit-slider-thumb { box-shadow: 0; - border: 0; + border: 0px; height: 10px; width: 10px; border-radius: 10px; @@ -168,7 +168,7 @@ .audiobox-button { width: 15px; height: 15px; - margin: 0; + margin: 0px; svg { width: 10px; diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.scss b/src/client/views/nodes/CollectionFreeFormDocumentView.scss index 7f0a39550..300533df8 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.scss +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.scss @@ -3,7 +3,7 @@ position: absolute; background-color: transparent; touch-action: manipulation; - top: 0; - left: 0; + top: 0px; + left: 0px; pointer-events: none; } diff --git a/src/client/views/nodes/ComparisonBox.scss b/src/client/views/nodes/ComparisonBox.scss index d2ba9796b..cbbd6bde3 100644 --- a/src/client/views/nodes/ComparisonBox.scss +++ b/src/client/views/nodes/ComparisonBox.scss @@ -18,7 +18,7 @@ } .input-box { position: absolute; - top: 50; + top: 50px; padding: 10px; width: 100%; height: 70%; @@ -33,7 +33,7 @@ padding-right: 5px; border-radius: 2px; height: 17%; - bottom: 0; + bottom: 0px; overflow: hidden; display: flex; width: 100%; @@ -101,7 +101,7 @@ position: absolute; display: inline-block; margin-top: 150px; - bottom: 0; + bottom: 0px; } .dropup-content { @@ -145,8 +145,8 @@ .clip-div { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; height: 100%; overflow: hidden; @@ -180,8 +180,8 @@ .afterBox-cont { position: absolute; - top: 0; - right: 0; + top: 0px; + right: 0px; height: 100%; width: 100%; overflow: hidden; @@ -331,8 +331,8 @@ justify-content: space-between; height: max-content; position: absolute; - bottom: 0; - right: 2; + bottom: 0px; + right: 2px; flex-direction: row-reverse; display: flex; cursor: pointer; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.scss b/src/client/views/nodes/DataVizBox/DataVizBox.scss index 9825d926f..32a01355e 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.scss +++ b/src/client/views/nodes/DataVizBox/DataVizBox.scss @@ -37,25 +37,25 @@ margin-left: 10px; margin-bottom: -10px; } - + .displaySchemaLive { margin-bottom: 20px; } .dataviz-sidebar { position: absolute; - right: 0; - top: 0; + right: 0px; + top: 0px; height: 100%; } .button-container { pointer-events: unset; } - .dataVizBox-annotationLayer{ + .dataVizBox-annotationLayer { position: absolute; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 9369ff98a..0b7033e57 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -420,6 +420,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }; + setRef = (r: LineChart | Histogram | PieChart | null) => (this._vizRenderer = r ?? undefined); // toggles for user to decide which chart type to view the data in @computed get renderVizView() { const scale = this._props.NativeDimScaling?.() || 1; @@ -437,9 +438,9 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (!this.records.length) return 'no data/visualization'; switch (this.dataVizView) { case DataVizView.TABLE: return <TableBox {...sharedProps} Doc={this.Document} specHighlightedRow={this._specialHighlightedRow} docView={this.DocumentView} selectAxes={this.selectAxes} selectTitleCol={this.selectTitleCol}/>; - case DataVizView.LINECHART: return <LineChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} vizBox={this} />; - case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} />; - case DataVizView.PIECHART: return <PieChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} + case DataVizView.LINECHART: return <LineChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={this.setRef} vizBox={this} />; + case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={this.setRef} />; + case DataVizView.PIECHART: return <PieChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={this.setRef} margin={{ top: 10, right: 15, bottom: 15, left: 15 }} />; default: } // prettier-ignore diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateManager.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateManager.ts new file mode 100644 index 000000000..526fcf9c4 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateManager.ts @@ -0,0 +1,91 @@ +import { action, makeAutoObservable } from 'mobx'; +import { Col } from '../DocCreatorMenu'; +import { FieldSettings, TemplateField } from '../TemplateFieldTypes/TemplateField'; +import { Template } from '../Template'; +import { NumListCast } from '../../../../../../fields/Doc'; +import { DataVizBox } from '../../DataVizBox'; +import { TemplateFieldType } from '../TemplateBackend'; +import { TemplateMenuAIUtils } from './TemplateMenuAIUtils'; + +export type Conditional = { + field: string; + operator: '=' | '>' | '<' | 'contains'; + condition: string; + target: string; + attribute: string; + value: string; +}; + +export class TemplateManager { + _templates: Template[] = []; + + _conditionalFieldLogic: Record<string, Conditional[]> = {}; + + constructor(templateSettings: FieldSettings[]) { + makeAutoObservable(this); + this._templates = templateSettings.map(settings => new Template(settings)); + } + + getValidTemplates = (cols: Col[]) => this._templates.filter(template => template.isValidTemplate(cols)); + + addTemplate = (newTemplate: Template) => this._templates.push(newTemplate); + + removeTemplate = (template: Template) => { + if (this._templates.includes(template)) { + this._templates.splice(this._templates.indexOf(template), 1); + } + template.cleanup(); + }; + + addFieldCondition = (fieldTitle: string, condition: Conditional) => { + if (this._conditionalFieldLogic[fieldTitle] === undefined) { + this._conditionalFieldLogic[fieldTitle] = [condition]; + } else { + this._conditionalFieldLogic[fieldTitle].push(condition); + } + }; + + removeFieldCondition = (fieldTitle: string, condition: Conditional) => (this._conditionalFieldLogic[fieldTitle] = this._conditionalFieldLogic[fieldTitle]?.filter(cond => cond !== condition)); + + addDataField = (title: string) => this._templates.forEach(template => template.addDataField(title)); + + removeDataField = (title: string) => this._templates.forEach(template => template.removeDataField(title)); + + createDocsFromTemplate = action((dv: DataVizBox, template: Template, cols: Col[], debug: boolean = false) => { + const csvFields = Array.from(Object.keys(dv.records[0])); + + const processContent = (content: { [title: string]: string }) => { + const templateCopy = template.clone(); + + csvFields + .filter(title => title) + .forEach(title => { + const field = templateCopy.getFieldByTitle(title); + field?.setContent(content[title], field.viewType); + }); + + const gptFunc = (type: TemplateFieldType) => (type === TemplateFieldType.VISUAL ? TemplateMenuAIUtils.renderGPTImageCall : TemplateMenuAIUtils.renderGPTTextCall); + + const generateGptContent = cols + .map(field => ({ field, templateField: field?.AIGenerated && templateCopy.getFieldByTitle(field.title) })) + .filter(({ templateField }) => templateField instanceof TemplateField) + .map(({ field, templateField }) => gptFunc(field.type)(templateCopy, field, (templateField as TemplateField).getID)); + + return Promise.all(generateGptContent).then(() => templateCopy.applyConditionalLogic(this._conditionalFieldLogic)); + }; + + const rowContents = debug + ? [{}, {}, {}, {}] + : NumListCast(dv.layoutDoc.dataViz_selectedRows).map(row => + csvFields.reduce( + (values, col) => { + values[col] = dv.records[row][col]; + return values; + }, + {} as { [title: string]: string } + ) + ); + + return Promise.all(rowContents.map(processContent)); + }); +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateMenuAIUtils.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateMenuAIUtils.ts new file mode 100644 index 000000000..08818dd6c --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateMenuAIUtils.ts @@ -0,0 +1,122 @@ +import { ClientUtils } from '../../../../../../ClientUtils'; +import { Networking } from '../../../../../Network'; +import { gptImageCall, gptAPICall, GPTCallType } from '../../../../../apis/gpt/GPT'; +import { Col } from '../DocCreatorMenu'; +import { TemplateFieldSize, TemplateFieldType } from '../TemplateBackend'; +import { ViewType } from '../TemplateFieldTypes/TemplateField'; +import { Template } from '../Template'; +import { Upload } from '../../../../../../server/SharedMediaTypes'; + +export class TemplateMenuAIUtils { + public static generateGPTImage = async (prompt: string): Promise<string | undefined> => { + try { + const res = await gptImageCall(prompt); + + if (res) { + const result = (await Networking.PostToServer('/uploadRemoteImage', { sources: res })) as Upload.FileInformation[]; + const source = ClientUtils.prepend(result[0].accessPaths.agnostic.client); + return source; + } + } catch (e) { + console.log(e); + } + }; + + public static renderGPTImageCall = async (template: Template, col: Col, fieldNumber: number): Promise<boolean> => { + const generateAndLoadImage = async (id: number, prompt: string) => { + const field = template.getFieldByID(id); + const url = await this.generateGPTImage(prompt); + field?.setContent(url ?? '', ViewType.IMG); + field?.setTitle(col.title); + }; + + const fieldContent: string = template.compiledContent; + + try { + const sysPrompt = + `#${Math.random() * 100}: 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); + + await generateAndLoadImage(fieldNumber, prompt); + } catch (e) { + console.log(e); + } + return true; + }; + + public static renderGPTTextCall = async (template: Template, col: Col, fieldNum: number | undefined): Promise<boolean> => { + 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 textAssignment = `--- title: ${col.title}, prompt: ${col.desc}, word limit: ${wordLimit(col.sizes[0])} words, assigned field: ${fieldNum} ---`; + + const fieldContent: string = template.compiledContent; + + try { + const prompt = fieldContent + textAssignment; + + const res = await gptAPICall(`${Math.random() * 100000}: ${prompt}`, GPTCallType.FILL); + + if (res) { + const assignments: { [title: string]: { number: string; content: string } } = JSON.parse(res); + Object.entries(assignments).forEach(([, /* title */ info]) => { + const field = template.getFieldByID(Number(info.number)); + + field?.setContent(info.content ?? '', ViewType.TEXT); + field?.setTitle(col.title); + }); + } + } catch (err) { + console.log(err); + } + + return true; + }; + + /** + * 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 + */ + public static applyGPTContentToTemplate = async (template: Template, assignments: { [field: string]: Col }): Promise<Template | undefined> => { + const GPTTextCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.TEXT && col.AIGenerated); + const GPTIMGCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.VISUAL && col.AIGenerated); + + if (GPTTextCalls.length) { + const promises = GPTTextCalls.map(([id, col]) => { + return TemplateMenuAIUtils.renderGPTTextCall(template, col, Number(id)); + }); + + await Promise.all(promises); + } + + if (GPTIMGCalls.length) { + const promises = GPTIMGCalls.map(async ([id, col]) => { + return TemplateMenuAIUtils.renderGPTImageCall(template, col, Number(id)); + }); + + await Promise.all(promises); + } + + return template; + }; +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss index 57f4a1e94..e2261b9e2 100644 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss @@ -9,10 +9,10 @@ position: absolute; z-index: 1000; // box-shadow: 0px 3px 4px rgba(0, 0, 0, 30%); - // background: whitesmoke; + // background: whitesmoke; // color: black; border-radius: 3px; -} +} .docCreatorMenu-menu { display: flex; @@ -24,57 +24,39 @@ .docCreatorMenu-menu-button { width: 25px; height: 25px; - background: whitesmoke; + 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; + background-color: rgb(60, 60, 65); } - &.close-menu { - font-size: 12px; - width: 18px; - height: 18px; - font-size: 12px; - margin-left: auto; - margin-right: 5px; - margin-bottom: 3px; + &.no-margin { + margin: 0px; } - &.options { - margin-left: 0px; + &.border { + border: 1px solid rgb(180, 180, 180); } - &:hover { - background-color: rgb(60, 60, 65); + &.float-right { + float: right; + margin-left: auto; } - &.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; + &.absolute-right { + position: absolute; + right: 0px; } - &.preview-toggle { - margin: 0px; - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; - border-left: 0px; + &.right { + margin-left: 0px; + font-size: 12px; } } @@ -121,31 +103,31 @@ } } - &:hover::before{ + &:hover::before { border-bottom: 20px solid rgb(82, 82, 82); } &::before { - content: ""; + content: ''; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; border-bottom: 20px solid rgb(50, 50, 50); border-left: 12px solid transparent; border-right: 12px solid transparent; - height: 0; + height: 0px; width: 50px; } &::after { - content: ""; + 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; + height: 0px; width: 52px; z-index: -1; } @@ -161,7 +143,7 @@ color: white; } -.docCreatorMenu-menu-hr{ +.docCreatorMenu-menu-hr { margin-top: 0px; margin-bottom: 0px; color: rgb(180, 180, 180); @@ -191,14 +173,14 @@ align-items: center; width: 40px; height: 40px; - background-color: rgb(99, 148, 238); + 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); + &:hover { + background-color: rgb(59, 128, 255); border: 2px solid rgb(53, 80, 127); } } @@ -206,7 +188,7 @@ .docCreatorMenu-create-docs-button { width: 40px; height: 40px; - background-color: rgb(176, 229, 149); + background-color: rgb(176, 229, 149); border: 2px solid rgb(126, 219, 80); border-radius: 5px; padding: 0px; @@ -217,7 +199,7 @@ &:hover { background-color: rgb(129, 223, 83); - border: 2px solid rgb(80, 185, 28); + border: 2px solid rgb(80, 185, 28); } } @@ -230,6 +212,14 @@ &.full { width: 100%; } + + &.no-margin-bottom { + margin-bottom: 0px; + } + + &.no-margin-top { + margin-top: 0px; + } } //------------------------------------------------------------------------------------------------------------------------------------------ @@ -240,17 +230,22 @@ position: absolute; background-color: none; - &.top, &.bottom { + &.top, + &.bottom { height: 10px; cursor: ns-resize; } - &.right, &.left { + &.right, + &.left { width: 10px; cursor: ew-resize; } - &.topRight, &.topLeft, &.bottomRight, &.bottomLeft { + &.topRight, + &.topLeft, + &.bottomRight, + &.bottomLeft { height: 15px; width: 15px; background-color: none; @@ -273,22 +268,10 @@ height: calc(100% - 30px); border: 1px solid rgb(180, 180, 180); border-radius: 5px; - -ms-overflow-style: none; + -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 { display: flex; flex-direction: column; @@ -297,8 +280,9 @@ position: relative; width: 100%; height: 100%; + flex-grow: 1; - .top-panel{ + .top-panel { width: 100%; height: 10px; } @@ -307,7 +291,7 @@ display: flex; flex-direction: column; justify-content: flex-start; - height: 100%; + height: fit-content; position: absolute; right: 0px; top: 0px; @@ -322,17 +306,15 @@ display: flex; justify-content: center; align-items: center; - width: 113px; - height: 113px; - margin-top: 10px; - margin-left: 10px; + height: 100%; + width: 100%; + aspect-ratio: 1; color: none; border: 1px solid rgb(163, 163, 163); border-radius: 5px; box-shadow: 5px 5px rgb(29, 29, 31); - flex: 0 0 auto; - &:hover{ + &:hover { background-color: rgb(72, 72, 73); } @@ -377,21 +359,18 @@ &:hover .option-button { display: block; } - } -.docCreatorMenu-preview-image{ +.docCreatorMenu-preview-image { background-color: transparent; - height: 100px; - width: 100px; + height: 100%; display: block; object-fit: contain; border-radius: 5px; +} - &.expanded { - height: 100%; - width: 100%; - } +.docCreatorMenu-variations-tab { + flex-grow: 0.5; } .docCreatorMenu-section { @@ -399,12 +378,12 @@ flex-direction: column; align-items: center; position: relative; + flex-grow: 1; + height: 100%; + width: 100%; margin: 0px; margin-top: 0px; margin-bottom: 0px; - width: 100%; - height: 200; - flex: 0 0 auto; } .docCreatorMenu-GPT-options-container { @@ -412,34 +391,35 @@ flex-direction: row; justify-content: center; align-items: center; - position: relative; - width: auto; + position: absolute; + left: 50%; + bottom: 0px; margin: 0px; + margin-bottom: 10px; 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; - color: black; - height: 125px; + display: grid; + justify-content: space-evenly; + row-gap: 2rem; + grid-template-columns: repeat(auto-fill, minmax(150px, 50%)); + margin: 5px; width: calc(100% - 10px); - -ms-overflow-style: none; - scrollbar-width: none; + height: 100%; + padding-bottom: 40px; - .loading-spinner { - justify-self: center; + &.scrolling { + overflow-y: scroll; + max-height: 300px; + padding-bottom: 0px; } } -.divvv{ - width: 200; - height: 200; +.div { + width: 200px; + height: 200px; border: solid 1px white; } @@ -447,20 +427,14 @@ position: relative; display: flex; flex-direction: row; + color: whitesmoke; 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-templates-displays { + display: flex; + flex-direction: column; + height: 100%; } .docCreatorMenu-section-title { @@ -478,7 +452,7 @@ .docCreatorMenu-GPT-generate { height: 30px; width: 30px; - background-color: rgb(176, 229, 149); + background-color: rgb(176, 229, 149); border: 1px solid rgb(126, 219, 80); border-radius: 5px; padding: 0px; @@ -489,7 +463,7 @@ &:hover { background-color: rgb(129, 223, 83); - border: 2px solid rgb(80, 185, 28); + border: 2px solid rgb(80, 185, 28); } } @@ -507,7 +481,7 @@ // DocCreatorMenu options CSS //-------------------------------------------------------------------------------------------------------------------------------------------- -.docCreatorMenu-option-container{ +.docCreatorMenu-option-container { display: flex; width: 180px; height: 30px; @@ -517,16 +491,16 @@ margin-top: 10px; margin-bottom: 10px; - &.layout{ + &.layout { z-index: 5; } } -.docCreatorMenu-option-title{ +.docCreatorMenu-option-title { display: flex; width: 140px; height: 30px; - background: whitesmoke; + background: whitesmoke; background-color: rgb(34, 34, 37); border-radius: 5px; border: 1px solid rgb(180, 180, 180); @@ -543,7 +517,7 @@ border-radius: 0px; width: auto; text-transform: none; - + &.small { height: 20px; transform: translateY(-5px); @@ -614,7 +588,7 @@ } .docCreatorMenu-configuration-bar { - width: 200; + width: 200px; gap: 5px; display: flex; flex-direction: row; @@ -637,38 +611,38 @@ height: calc(100% - 30px); border: 1px solid rgb(180, 180, 180); border-radius: 5px; - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; - .docCreatorMenu-option-container{ + .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; + -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{ + + .docCreatorMenu-dropdown-option { display: flex; background-color: rgb(42, 42, 46); border-left: 1px solid rgb(180, 180, 180); @@ -679,17 +653,30 @@ justify-content: center; justify-items: center; padding-top: 3px; - + &:hover { background-color: rgb(68, 68, 74); cursor: pointer; } } - } + } } } } +.loading-spinner { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + z-index: 200; + font-size: 20px; + font-weight: bold; + color: #17175e; +} + .docCreatorMenu-layout-preview-window-wrapper { flex: 0 0 auto; display: flex; @@ -722,17 +709,17 @@ width: 100%; aspect-ratio: 1; //height: auto; - // max-width: 240; - // max-height: 240; + // max-width: 240px; + // max-height: 240px; border: 1px solid rgb(180, 180, 180); border-radius: 5px; background-color: rgb(34, 34, 37); - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; &.small { - max-width: 100; - max-height: 100; + max-width: 100px; + max-height: 100px; } .docCreatorMenu-layout-preview-item { @@ -759,10 +746,10 @@ z-index: 999; } - .docCreatorMenu-zoom-button{ + .docCreatorMenu-zoom-button { width: 15px; height: 15px; - background: whitesmoke; + background: whitesmoke; background-color: rgb(34, 34, 37); border-radius: 3px; border: 1px solid rgb(180, 180, 180); @@ -793,10 +780,11 @@ height: calc(100% - 30px); border: 1px solid rgb(180, 180, 180); border-radius: 5px; - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; .panels-container { + display: flex; height: 100%; width: 100%; flex-direction: column; @@ -810,114 +798,12 @@ 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; + height: fit-content; width: calc(100% - 10px); border: 1px solid rgb(180, 180, 180); margin: 5px; @@ -938,15 +824,22 @@ border-top-right-radius: 5px; border-top-left-radius: 5px; width: 100%; - height: 20px; + height: fit-content; background-color: rgb(50, 50, 50); color: rgb(168, 167, 167); + font-size: medium; .field-title { color: whitesmoke; + font-size: large; } - } - + + &:hover { + background-color: rgb(72, 72, 72); + cursor: pointer; + } + } + .opts-bar { display: flex; flex-direction: row; @@ -989,11 +882,11 @@ flex-direction: row; align-items: flex-start; } - + &:hover .type-display { display: none; } - + .bubble { margin: 3px; } @@ -1028,7 +921,7 @@ flex-direction: row; align-items: center; } - + .bubble { margin: 3px; margin-right: 4px; @@ -1038,23 +931,255 @@ .desc-box { width: 88%; - height: 50px; + height: fit-content; 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); + height: fit-content; width: 100%; background-color: rgb(50, 50, 50); border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; resize: none; + } + } + } + .conditionals-section { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + width: 100%; + + .conditionals-title { + display: flex; + flex-direction: row; + width: 100%; + justify-content: center; + align-items: center; + margin: 5px; + margin-bottom: 20px; + font-size: large; + } + } + + .form-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + color: whitesmoke; + width: 100%; + height: fit-content; + margin-bottom: 15px; + flex-wrap: wrap; + gap: 5px; + + .form-row-plain-text { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: fit-content; + padding-top: 2px; + padding-bottom: 2px; + } + + .operator-options-dropdown { + display: flex; + flex-direction: column; + height: fit-content; + + .operator-dropdown-option { + display: none; + } + + .operator-dropdown-current { + border-radius: 5px; + background-color: rgb(50, 50, 50); + border: 1px solid rgb(180, 180, 180); + text-align: center; + padding: 2.25px; + padding-left: 4px; + padding-right: 4px; + } + + &:hover .operator-dropdown-current { + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + } + + &:hover .operator-dropdown-option { + display: flex; + height: fit-content; + align-items: center; + border: 1px solid rgb(180, 180, 180); + background-color: rgb(50, 50, 50); + padding: 2.25px; + padding-left: 8px; + padding-right: 8px; + text-align: center; + + &:hover { + background-color: rgb(70, 70, 70); + cursor: pointer; + } } } + .form-row-textarea { + height: 24px; + width: 110px; + border-radius: 5px; + background-color: rgb(50, 50, 50); + border: 1px solid rgb(180, 180, 180); + resize: none; + overflow-y: scroll; + white-space: nowrap; + } + } + + .form { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + width: 80%; + + .form-action-button { + display: flex; + justify-content: center; + align-items: center; + margin: 3px; + cursor: pointer; + } } +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// EditingWindow CSS +//-------------------------------------------------------------------------------------------------------------------------------------------- +.docCreatorMenu-editing-firefly-section { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + padding: 5px; +} + +.docCreatorMenu-firefly-options { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: fit-content; + width: 100%; +} + +.docCreatorMenu-variation-prompt-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 15px; + height: fit-content; + width: 100%; +} + +.docCreatorMenu-variation-prompt-input-textbox { + height: 40px; + width: 80%; + color: white; + margin-top: 1%; + margin-bottom: 1%; + margin-left: 5%; + background-color: rgb(50, 50, 50); + border-radius: 5px; + overflow: hidden; + resize: none; +} + +.options‑menu { + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; + padding: 0.5rem 1rem; + background: rgb(50, 50, 50); + color: whitesmoke; + font-family: system-ui, sans-serif; + font-size: 0.9rem; + flex-wrap: wrap; +} + +.menu‑item { + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +.menu‑item input[type='range'] { + width: 7rem; + accent-color: whitesmoke; +} + +.value { + min-width: 2ch; + text-align: right; +} + +.switch { + gap: 0.75rem; + margin-bottom: 0px; +} + +.switch .slider { + position: relative; + width: 2.2rem; + height: 1.1rem; + background: whitesmoke; + border-radius: 1rem; + cursor: pointer; + transition: background 0.2s; +} + +.switch .slider::before { + content: ''; + position: absolute; + top: 0.1rem; + left: 0.1rem; + width: 0.9rem; + height: 0.9rem; + background: rgb(50, 50, 50); + border-radius: 50%; + transition: transform 0.2s; +} + +.switch input { + display: none; +} + +.switch input:checked + .slider { + background: #78c2f1; +} + +.switch input:checked + .slider::before { + transform: translateX(1.1rem); +} + +.firefly-option-label { + padding: 0.2em 0.6em 0.3em; + font-size: 100%; + color: whitesmoke; + text-align: center; + margin-bottom: 0px; + font-weight: 500; } diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx index 64416c26d..8f6ecab57 100644 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx @@ -1,36 +1,32 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Colors } from '@dash/components'; import { action, computed, makeObservable, observable, 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, returnEmptyFilter, returnFalse, setupMoveUpEvents } from '../../../../../ClientUtils'; +import { returnFalse, setupMoveUpEvents } from '../../../../../ClientUtils'; import { emptyFunction } from '../../../../../Utils'; -import { Doc, NumListCast, StrListCast, returnEmptyDoclist } from '../../../../../fields/Doc'; +import { Doc, StrListCast } from '../../../../../fields/Doc'; import { Id } from '../../../../../fields/FieldSymbols'; -import { ImageCast, StrCast } from '../../../../../fields/Types'; -import { ImageField } from '../../../../../fields/URLField'; -import { Networking } from '../../../../Network'; -import { GPTCallType, gptAPICall, gptImageCall } from '../../../../apis/gpt/GPT'; -import { Docs, DocumentOptions } from '../../../../documents/Documents'; +import { GPTCallType, gptAPICall } from '../../../../apis/gpt/GPT'; import { DragManager } from '../../../../util/DragManager'; import { SnappingManager } from '../../../../util/SnappingManager'; import { UndoManager, undoable } from '../../../../util/UndoManager'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; import { CollectionFreeFormView } from '../../../collections/collectionFreeForm/CollectionFreeFormView'; -import { DocumentView, DocumentViewInternal } from '../../DocumentView'; +import { DocumentView } from '../../DocumentView'; import { OpenWhere } from '../../OpenWhere'; import { DataVizBox } from '../DataVizBox'; import './DocCreatorMenu.scss'; -import { DefaultStyleProvider } from '../../../StyleProvider'; -import { Transform } from '../../../../util/Transform'; -import { TemplateFieldSize, TemplateFieldType, TemplateLayouts } from './TemplateBackend'; -import { TemplateManager } from './TemplateManager'; +import { ViewType } from './TemplateFieldTypes/TemplateField'; import { Template } from './Template'; -import { Field, FieldContentType } from './FieldTypes/Field'; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { Upload } from '../../../../../server/SharedMediaTypes'; +import { TemplateFieldSize, TemplateFieldType, TemplateLayouts } from './TemplateBackend'; +import { TemplateManager } from './Backend/TemplateManager'; +import { TemplateMenuAIUtils } from './Backend/TemplateMenuAIUtils'; +import { TemplatePreviewGrid } from './Menu/TemplatePreviewGrid'; +import { FireflyStructureOptions, TemplateEditingWindow } from './Menu/TemplateEditingWindow'; +import { DocCreatorMenuButton } from './Menu/DocCreatorMenuButton'; +import { TemplatesRenderPreviewWindow } from './Menu/TemplateRenderPreviewWindow'; +import { TemplateMenuFieldOptions } from './Menu/TemplateMenuFieldOptions'; export enum LayoutType { FREEFORM = 'Freeform', @@ -61,6 +57,7 @@ export type Col = { title: string; type: TemplateFieldType; defaultContent?: string; + AIGenerated?: boolean; }; interface DocCreateMenuProps { @@ -72,33 +69,20 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> // eslint-disable-next-line no-use-before-define static Instance: DocCreatorMenu; - private _disposers: { [name: string]: IDisposer } = {}; - + DEBUG_MODE: boolean = false; private _ref: HTMLDivElement | null = null; - private templateManager: TemplateManager; - @observable _fullyRenderedDocs: Doc[] = []; - @observable _renderedDocCollectionPreview: Doc | undefined = undefined; - @observable _renderedDocCollection: Doc | undefined = undefined; - @observable _docsRendering: boolean = false; + @observable _docsRendering: boolean = false; // dictates loading symbol - @observable _userTemplates: { template: Template; doc: Doc }[] = []; //!!! used to keep track of all templates, should be refactored to work with actual templates and not docs + @observable _userTemplates: Template[] = []; @observable _selectedTemplate: Template | undefined = undefined; @observable _currEditingTemplate: Template | undefined = undefined; + @observable _editedTemplateTrail: Template[] = []; @observable _userCreatedFields: Col[] = []; - @observable _selectedCols: { title: string; type: string; desc: string }[] | undefined = []; - - @observable _layout: { type: LayoutType; yMargin: number; xMargin: number; columns?: number; repeat: number } = { type: LayoutType.FREEFORM, yMargin: 10, xMargin: 10, columns: 3, repeat: 0 }; - @observable _layoutPreviewScale: number = 1; - @observable _savedLayouts: DataVizTemplateLayout[] = []; - @observable _expandedPreview: Doc | undefined = undefined; @observable _suggestedTemplates: Template[] = []; - @observable _suggestedTemplatePreviews: { doc: Doc; template: Template }[] = []; - @observable _GPTOpt: boolean = false; - @observable _callCount: number = 0; @observable _GPTLoading: boolean = false; @observable _pageX: number = 0; @@ -110,9 +94,8 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> @observable _startPos?: { x: number; y: number }; @observable _shouldDisplay: boolean = false; - @observable _menuContent: 'templates' | 'options' | 'saved' | 'dashboard' = 'templates'; + @observable _menuContent: 'templates' | 'renderPreview' | 'saved' | 'dashboard' | 'templateEditing' = 'templates'; @observable _dragging: boolean = false; - @observable _draggingIndicator: boolean = false; @observable _dataViz?: DataVizBox; @observable _interactionLock: boolean | undefined; @observable _snapPt: { x: number; y: number } = { x: 0, y: 0 }; @@ -122,7 +105,8 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> @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; + + @observable _variations: Template[] = []; constructor(props: DocCreateMenuProps) { super(props); @@ -134,56 +118,13 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> @action setDataViz = (dataViz: DataVizBox) => { this._dataViz = dataViz; this._selectedTemplate = undefined; - this._renderedDocCollection = undefined; - this._renderedDocCollectionPreview = undefined; - this._fullyRenderedDocs = []; - this._suggestedTemplatePreviews = []; this._suggestedTemplates = []; this._userCreatedFields = []; }; - @action addUserTemplate = (template: Template) => { - this._userTemplates.push({ template: template.cloneBase(), doc: template.getRenderedDoc() }); - }; - @action removeUserTemplate = (template: Template) => { - this._userTemplates = this._userTemplates.filter(info => info.template !== template); - }; - @action updateTemplatePreview = (template: Template) => { - template.renderUpdates(); - const preview = { template: template, doc: template.getRenderedDoc() }; - this._suggestedTemplatePreviews = this._suggestedTemplatePreviews.map(t => { return t.template === preview.template ? preview : t }); //prettier-ignore - this._userTemplates = this._userTemplates.map(t => { return t.template === preview.template ? preview : t }); //prettier-ignore - }; @action setSuggestedTemplates = (templates: Template[]) => { - this._suggestedTemplates = templates; - this._suggestedTemplatePreviews = templates.map(template => {return {template: template, doc: template.getRenderedDoc()}}); //prettier-ignore + this._suggestedTemplates = templates; //prettier-ignore }; - @computed get docsToRender() { - return this._selectedTemplate ? NumListCast(this._dataViz?.layoutDoc.dataViz_selectedRows) : []; - } - - @computed get rowsCount() { - switch (this._layout.type) { - case LayoutType.FREEFORM: - return Math.ceil(this.docsToRender.length / (this._layout.columns ?? 1)) ?? 0; - case LayoutType.CAROUSEL3D: - return 1.8; - default: - return 1; - } - } - - @computed get columnsCount() { - switch (this._layout.type) { - case LayoutType.FREEFORM: - return this._layout.columns ?? 0; - case LayoutType.CAROUSEL3D: - return 3; - default: - return 1; - } - } - @computed get selectedFields() { return StrListCast(this._dataViz?.layoutDoc._dataViz_axes); } @@ -210,10 +151,6 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> .concat(this._userCreatedFields); } - @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 }; @@ -221,17 +158,11 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> } setUpButtonClick = (e: React.PointerEvent, func: () => void) => { - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - clickEv.preventDefault(); - func(); - }, 'create docs') - ); + setupMoveUpEvents(this, e, returnFalse, emptyFunction, clickEv => { + clickEv.stopPropagation(); + clickEv.preventDefault(); + undoable(func, 'create docs')(); + }); }; @action @@ -269,7 +200,6 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> } componentWillUnmount() { - Object.values(this._disposers).forEach(disposer => disposer?.()); document.removeEventListener('pointerdown', this.onPointerDown, true); document.removeEventListener('pointerup', this.onPointerUp); } @@ -319,10 +249,10 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> 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<boolean | undefined>(res => { setTimeout(() => { res(this._interactionLock = undefined)})}); + this._interactionLock = true; + const scaleAspect = {x: scale.x, y: scale.y}; + this.resizeView(refPt, scaleAspect, transl); // prettier-ignore + await new Promise<boolean | undefined>(res => { setTimeout(() => { res(this._interactionLock = undefined)})}); }); // prettier-ignore return true; }; @@ -365,66 +295,29 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> 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; + async createDocsForPreview() { + return this._dataViz && this._selectedTemplate ? ((await this.templateManager.createDocsFromTemplate(this._dataViz, this._selectedTemplate, this.fieldsInfos, this.DEBUG_MODE)).filter(doc => doc).map(doc => doc!) ?? []) : []; } - @action updateSelectedTemplate = async (template: Template) => { - if (this._selectedTemplate === template) { - this._selectedTemplate = undefined; - return; - } else { - this._selectedTemplate = template; - template.renderUpdates(); - this._fullyRenderedDocs = (await this.createDocsFromTemplate(template)) ?? []; - this.updateRenderedDocCollection(); - } - }; - - @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 updateSelectedTemplate = (template: Template) => { + this._selectedTemplate = this._selectedTemplate === template ? undefined : template; // toggle selection }; - editTemplate = (doc: Doc) => { - DocumentViewInternal.addDocTabFunc(doc, OpenWhere.addRight); - DocumentView.DeselectAll(); - Doc.UnBrushDoc(doc); - }; + // testTemplate = async () => { + // this._suggestedTemplates = this.templateManager.templates; //prettier-ignore + // }; @action addField = () => { - const newFields: Col[] = this._userCreatedFields.concat([{ title: '', type: TemplateFieldType.UNSET, desc: '', sizes: [] }]); - this._userCreatedFields = newFields; + this._userCreatedFields = this._userCreatedFields.concat([{ title: '', type: TemplateFieldType.UNSET, desc: '', sizes: [], AIGenerated: true }]); }; @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._userCreatedFields.filter(f => f === field); - if (!toRemove) return; - - if (toRemove.length > 1) { - while (toRemove.length > 1) { - toRemove.pop(); - } - } - - if (this._userCreatedFields.length === 1) { - this._userCreatedFields = []; - } else { - this._userCreatedFields.splice(this._userCreatedFields.indexOf(toRemove[0]), 1); + const toRemove = this._userCreatedFields.findIndex(f => f === field); + if (toRemove !== -1) { + this._userCreatedFields.splice(toRemove, 1); } } }; @@ -439,11 +332,18 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> }; @action setColType = (column: Col, type: TemplateFieldType) => { + if (type === TemplateFieldType.DATA) { + this.templateManager.addDataField(column.title); + } else if (column.type === TemplateFieldType.DATA) { + this.templateManager.removeDataField(column.title); + } + if (this.selectedFields.includes(column.title)) { this._dataViz?.setColumnType(column.title, type); } else { column.type = type; } + this.forceUpdate(); }; @@ -469,81 +369,22 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> this.forceUpdate(); }; - generateGPTImage = async (prompt: string): Promise<string | undefined> => { - try { - const res = await gptImageCall(prompt); - - if (res) { - const result = (await Networking.PostToServer('/uploadRemoteImage', { sources: res })) as Upload.FileInformation[]; - const source = ClientUtils.prepend(result[0].accessPaths.agnostic.client); - return source; - } - } catch (e) { - console.log(e); - } - }; - - /** - * 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 - */ - applyGPTContentToTemplate = async (template: Template, assignments: { [field: string]: Col }): Promise<Template | undefined> => { - const GPTTextCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.TEXT && this._userCreatedFields.includes(col)); - const GPTIMGCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.VISUAL && this._userCreatedFields.includes(col)); - - if (GPTTextCalls.length) { - const promises = GPTTextCalls.map(([str, col]) => { - return this.renderGPTTextCall(template, col, Number(str)); - }); - - await Promise.all(promises); - } - - if (GPTIMGCalls.length) { - const promises = GPTIMGCalls.map(async ([fieldNum, col]) => { - return this.renderGPTImageCall(template, col, Number(fieldNum)); - }); - - await Promise.all(promises); - } - - return template; - }; - - compileFieldDescriptions = (templates: Template[]): string => { - let descriptions: string = ''; - templates.forEach(template => { - descriptions += `---------- NEW TEMPLATE TO INCLUDE: The title is: ${template.mainField.getTitle()}. Its fields are: `; - descriptions += template.descriptionSummary; - }); + compileFieldDescriptions = (templates: Template[]) => + templates.map(template => `---------- NEW TEMPLATE TO INCLUDE: The title is: ${template.title}. Its fields are: ` + template.descriptionSummary).join(''); // prettier-ignore - return descriptions; - }; + compileColDescriptions = (cols: Col[]) => + ' ------------- COL DESCRIPTIONS START HERE:' + cols.map(col => `{title: ${col.title}, sizes: ${String(col.sizes)}, type: ${col.type}, descreiption: ${col.desc}} `).join(''); // prettier-ignore - 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]; - }; + getColByTitle = (title: string): Col | undefined => this.fieldsInfos.filter(col => col.title === title)[0]; @action assignColsToFields = async (templates: Template[], cols: Col[]): Promise<[Template, { [field: number]: Col }][]> => { - const fieldDescriptions: string = this.compileFieldDescriptions(templates); - const colDescriptions: string = this.compileColDescriptions(cols); + const fieldDescriptions = this.compileFieldDescriptions(templates); + const colDescriptions = this.compileColDescriptions(cols); const inputText = fieldDescriptions.concat(colDescriptions); - ++this._callCount; - const origCount = this._callCount; - - const prompt: string = `(${origCount}) ${inputText}`; + const prompt = `(${Math.random() * 100000}) ${inputText}`; this._GPTLoading = true; @@ -555,24 +396,26 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> const brokenDownAssignments: [Template, { [fieldID: number]: Col }][] = []; Object.entries(assignments).forEach(([tempTitle, assignment]) => { - const template = templates.filter(t => t.mainField.getTitle() === tempTitle)[0]; - if (!template) return; - const toObj = Object.entries(assignment).reduce( - (a, [fieldID, colTitle]) => { - const col = this.getColByTitle(colTitle); - if (!this._userCreatedFields.includes(col)) { - // do the following for any fields not added by the user; will change in the future, for now only GPT content works with user-added fields - const field = template.getFieldByID(Number(fieldID)); - field.setContent(col.defaultContent ?? '', col.type === TemplateFieldType.VISUAL ? FieldContentType.IMAGE : FieldContentType.STRING); - field.setTitle(col.title); - } else { - a[Number(fieldID)] = this.getColByTitle(colTitle); - } - return a; - }, - {} as { [field: number]: Col } - ); - brokenDownAssignments.push([template, toObj]); + const template = templates.filter(temp => temp.title === tempTitle)[0]; + if (template) { + const toObj = Object.entries(assignment).reduce( + (a, [fieldID, colTitle]) => { + const col = this.getColByTitle(colTitle); + if (col) { + if (!col.AIGenerated) { + const field = template.getFieldByID(Number(fieldID)); + field?.setContent(col.defaultContent ?? '', col.type === TemplateFieldType.VISUAL ? ViewType.IMG : ViewType.TEXT); + field?.setTitle(col.title); + } else { + a[Number(fieldID)] = col; + } + } + return a; + }, + {} as { [field: number]: Col } + ); + brokenDownAssignments.push([template, toObj]); + } }); return brokenDownAssignments; @@ -584,777 +427,100 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> return []; }; - generatePresetTemplates = async () => { - this._dataViz?.updateColDefaults(); - - const cols = this.fieldsInfos; - const templates = this.templateManager.getValidTemplates(cols); - - const assignments: [Template, { [field: number]: Col }][] = await this.assignColsToFields(templates, cols); - - const renderedTemplatePromises: Promise<Template | undefined>[] = assignments.map(([template, asns]) => this.applyGPTContentToTemplate(template, asns)); - - await Promise.all(renderedTemplatePromises); - - setTimeout(() => { - this.setSuggestedTemplates(templates); + generatePresetTemplates = action(() => { + if (this.DEBUG_MODE) { + this.setSuggestedTemplates(this.templateManager._templates); this._GPTLoading = false; - }); - }; - - renderGPTImageCall = async (template: Template, col: Col, fieldNumber: number): Promise<boolean> => { - const generateAndLoadImage = async (fieldNum: string, column: Col, prompt: string) => { - const url = await this.generateGPTImage(prompt); - const field: Field = template.getFieldByID(Number(fieldNum)); - - field.setContent(url ?? '', FieldContentType.IMAGE); - field.setTitle(column.title); - }; - - const fieldContent: string = template.compiledContent; - - try { - 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); - - await generateAndLoadImage(String(fieldNumber), col, prompt); - } catch (e) { - console.log(e); + } else { + this._dataViz?.updateColDefaults(); + const contentFields = this.fieldsInfos.filter(field => field.type !== TemplateFieldType.DATA); + const templates = this.templateManager.getValidTemplates(contentFields); + + return this.assignColsToFields(templates, contentFields) + .then(pairs => + Promise.all(pairs.map(([templ, assgns]) => TemplateMenuAIUtils.applyGPTContentToTemplate(templ, assgns)))) + .then(action(() => { + this.setSuggestedTemplates(templates); + this._GPTLoading = false; + })); // prettier-ignore } - return true; - }; - - renderGPTTextCall = async (template: Template, col: Col, fieldNum: number): Promise<boolean> => { - 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 textAssignment = `--- title: ${col.title}, prompt: ${col.desc}, word limit: ${wordLimit(col.sizes[0])} words, assigned field: ${fieldNum} ---`; + }); - const fieldContent: string = template.compiledContent; - - try { - const prompt = fieldContent + textAssignment; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + generateVariations = async (onDoc: Doc, prompt: string, options: FireflyStructureOptions) => { + // const { numVariations, temperature, useStyleRef } = options; + this.variations = []; + const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; - const res = await gptAPICall(`${++this._callCount}: ${prompt}`, GPTCallType.FILL); + const clone = Doc.MakeClone(onDoc).clone; + mainCollection.addDocument(clone); + clone.x = 10000; + clone.y = 10000; - if (res) { - const assignments: { [title: string]: { number: string; content: string } } = JSON.parse(res); - Object.entries(assignments).forEach(([title, info]) => { - const field: Field = template.getFieldByID(Number(info.number)); - const column = this.getColByTitle(title); - - field.setContent(info.content ?? '', FieldContentType.STRING); - field.setTitle(column.title); - }); - } - } catch (err) { - console.log(err); - } + // await DrawingFillHandler.drawingToImage(clone, 100 - temperature, prompt, useStyleRef ? clone : undefined, this, numVariations) - return true; + return this.variations; }; - createDocsFromTemplate = async (template: Template) => { - const dv = this._dataViz; - - if (!dv) return; - - this._docsRendering = true; - - const fields: string[] = Array.from(Object.keys(dv.records[0])); - const selectedRows = NumListCast(dv.layoutDoc.dataViz_selectedRows); - - const rowContents: { [title: string]: string }[] = selectedRows.map(row => { - const values: { [title: string]: string } = {}; - fields.forEach(col => { - values[col] = dv.records[row][col]; - }); + variations: string[] = []; - return values; - }); - - const processContent = async (content: { [title: string]: string }) => { - const templateCopy = template.cloneBase(); - - fields - .filter(title => title) - .forEach(title => { - const field = templateCopy.getFieldByTitle(title); - if (field === undefined) { - return; - } - field.setContent(content[title]); - }); - - const gptPromises = this._userCreatedFields - .filter(field => field.type === TemplateFieldType.TEXT) - .map(field => { - const title = field.title; - const templateField = templateCopy.getFieldByTitle(title); - if (templateField === undefined) { - return; - } - const templatefieldID = templateField.getID; - - return this.renderGPTTextCall(templateCopy, field, templatefieldID); - }); - - const imagePromises = this._userCreatedFields - .filter(field => field.type === TemplateFieldType.VISUAL) - .map(field => { - const title = field.title; - const templateField = templateCopy.getFieldByTitle(title); - if (templateField === undefined) { - return; - } - const templatefieldID = templateField.getID; - - return this.renderGPTImageCall(templateCopy, field, templatefieldID); - }); - - await Promise.all(gptPromises); - - await Promise.all(imagePromises); - - return templateCopy.getRenderedDoc(); - }; - - const promises = rowContents.map(content => processContent(content)); - - const renderedDocs = await Promise.all(promises); - - this._docsRendering = false; + @action addVariation = (url: string) => { + this.variations.push(url); + }; - return renderedDocs; + addRenderedCollectionToMainview = (collection: Doc) => { + if (collection) { + const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; + collection.x = this._pageX - this._menuDimensions.width; + collection.y = this._pageY - this._menuDimensions.height; + mainCollection?.addDocument(collection); + this.closeMenu(); + } }; - addRenderedCollectionToMainview = () => { - const collection = this._renderedDocCollection; - if (!collection) return; - const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; - collection.x = this._pageX - this._menuDimensions.width; - collection.y = this._pageY - this._menuDimensions.height; - mainCollection.addDocument(collection); - this.closeMenu(); + @action editLastTemplate = () => { + if (this._editedTemplateTrail.length) this._currEditingTemplate = this._editedTemplateTrail.pop(); }; @action setExpandedView = (template: Template | undefined) => { if (template) { - this._currEditingTemplate = template; - this._expandedPreview = template.mainField.renderedDoc(); //Docs.Create.FreeformDocument([doc], { _height: NumListCast(doc._height)[0], _width: NumListCast(doc._width)[0], title: ''}); + this._menuContent = 'templateEditing'; + this._currEditingTemplate && this._editedTemplateTrail.push(this._currEditingTemplate); } else { - this._currEditingTemplate = undefined; - this._expandedPreview = undefined; - } - }; - - get editingWindow() { - const rendered = !this._expandedPreview ? null : ( - <div className="docCreatorMenu-expanded-template-preview"> - <DocumentView - Document={this._expandedPreview} - isContentActive={emptyFunction} - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => this._menuDimensions.width - 10} - PanelHeight={() => this._menuDimensions.height - 60} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={5} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={DocumentViewInternal.addDocTabFunc} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - /> - </div> - ); - - return ( - <div className="docCreatorMenu-expanded-template-preview"> - <div className="top-panel" /> - {rendered} - <div className="right-buttons-panel"> - <button - className="docCreatorMenu-menu-button section-reveal-options top-right" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this._currEditingTemplate && this.updateTemplatePreview(this._currEditingTemplate); - this.setExpandedView(undefined); - }) - }> - <FontAwesomeIcon icon="minimize" /> - </button> - <button - className="docCreatorMenu-menu-button section-reveal-options top-right-lower" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this._currEditingTemplate?.resetToBase(); - this.setExpandedView(this._currEditingTemplate); - }) - }> - <FontAwesomeIcon icon="arrows-rotate" color="white" /> - </button> - </div> - </div> - ); - } - - get templatesPreviewContents() { - const GPTOptions = <div></div>; - - return ( - <div className={`docCreatorMenu-templates-view`}> - {this._expandedPreview ? ( - this.editingWindow - ) : ( - <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._suggestedTemplatePreviews.map(({ doc, template }) => ( - <div - className="docCreatorMenu-preview-window" - key="0" - style={{ - border: this._selectedTemplate === template ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', - boxShadow: this._selectedTemplate === template ? `0 0 15px rgba(68, 118, 247, .8)` : '', - }} - onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this.updateSelectedTemplate(template)))}> - <button - className="option-button left" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this.setExpandedView(template); - }) - }> - <FontAwesomeIcon icon="magnifying-glass" color="white" /> - </button> - <button className="option-button right" onPointerDown={e => this.setUpButtonClick(e, () => this.addUserTemplate(template))}> - <FontAwesomeIcon icon="plus" color="white" /> - </button> - <DocumentView - Document={doc} - isContentActive={emptyFunction} // !!! should be return false - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => (this._selectedTemplate === template ? 104 : 111)} - PanelHeight={() => (this._selectedTemplate === template ? 104 : 111)} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={1} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={this._props.addDocTab} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - hideDecorations={true} - /> - </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"> - <FontAwesomeIcon icon="plus" color="rgb(160, 160, 160)" /> - </div> - {this._userTemplates.map(({ template, doc }) => ( - <div - className="docCreatorMenu-preview-window" - key="0" - style={{ - border: this._selectedTemplate === template ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', - boxShadow: this._selectedTemplate === template ? `0 0 15px rgba(68, 118, 247, .8)` : '', - }} - onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this.updateSelectedTemplate(template)))}> - <button - className="option-button left" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this.setExpandedView(template); - }) - }> - <FontAwesomeIcon icon="magnifying-glass" color="white" /> - </button> - <button className="option-button right" onPointerDown={e => this.setUpButtonClick(e, () => this.removeUserTemplate(template))}> - <FontAwesomeIcon icon="minus" color="white" /> - </button> - <DocumentView - Document={doc} - isContentActive={emptyFunction} // !!! should be return false - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => (this._selectedTemplate === template ? 104 : 111)} - PanelHeight={() => (this._selectedTemplate === template ? 104 : 111)} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={1} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={this._props.addDocTab} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - hideDecorations={true} - /> - </div> - ))} - </div> - </div> - </div> - )} - </div> - ); - } - - @action updateXMargin = (input: string) => { - this._layout.xMargin = Number(input); - setTimeout(() => { - if (!this._renderedDocCollection || !this._fullyRenderedDocs) return; - this.applyLayout(this._renderedDocCollection, this._fullyRenderedDocs); - }); - }; - @action updateYMargin = (input: string) => { - this._layout.yMargin = Number(input); - setTimeout(() => { - if (!this._renderedDocCollection || !this._fullyRenderedDocs) return; - this.applyLayout(this._renderedDocCollection, this._fullyRenderedDocs); - }); - }; - @action updateColumns = (input: string) => { - this._layout.columns = Number(input); - this.updateRenderedDocCollection(); - }; - - get layoutConfigOptions() { - const optionInput = (icon: string, func: (input: string) => void, 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 IconProp} /> - </div> - <input defaultValue={def} onInput={e => func(e.currentTarget.value)} className="docCreatorMenu-input config layout-config" /> - </div> - ); - }; - - switch (this._layout.type) { - case LayoutType.FREEFORM: - 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> - ); - default: - break; + this._menuContent = 'templates'; } - } - applyLayout = (collection: Doc, docs: Doc[]) => { - const { horizontalSpan, verticalSpan } = this.previewInfo; - collection._height = verticalSpan; - collection._width = horizontalSpan; - - const layout = this._layout; - const columns: number = layout.columns ?? this.columnsCount; - const xGap: number = layout.xMargin; - const yGap: number = layout.yMargin; - // const repeat: number = templateInfo.layout.repeat; - const startX: number = -Number(collection._width) / 2; - const startY: number = -Number(collection._height) / 2; - const docHeight: number = Number(docs[0]._height); - const docWidth: number = Number(docs[0]._width); - - if (columns === 0 || docs.length === 0) { - return; - } + this._currEditingTemplate = template; - 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 += docWidth + xGap; - ++docsChanged; - ++i; - } - i = 0; - curX = startX; - curY += docHeight + yGap; - } + //Docs.Create.FreeformDocument([doc], { _height: NumListCast(doc._height)[0], _width: NumListCast(doc._width)[0], title: ''}); }; @computed - get previewInfo() { - const docHeight: number = Number(this._fullyRenderedDocs[0]._height); - const docWidth: number = Number(this._fullyRenderedDocs[0]._width); - const layout = this._layout; - return { - docHeight: docHeight, - docWidth: docWidth, - horizontalSpan: (docWidth + layout.xMargin) * this.columnsCount - layout.xMargin, - verticalSpan: (docHeight + layout.yMargin) * this.rowsCount - layout.yMargin, - }; - } - - /** - * Updates the preview that shows how all docs will be rendered in the chosen collection type. - @type the type of collection the docs should render to (ie. freeform, carousel, card) - */ - updateRenderedDocCollection = () => { - if (!this._fullyRenderedDocs) return; - - const { horizontalSpan, verticalSpan } = this.previewInfo; - - const collectionFactory = (): ((docs: Doc[], options: DocumentOptions) => Doc) => { - switch (this._layout.type) { - case LayoutType.CAROUSEL3D: - return Docs.Create.Carousel3DDocument; - case LayoutType.FREEFORM: - return Docs.Create.FreeformDocument; - case LayoutType.CARD: - return Docs.Create.CardDeckDocument; - case LayoutType.MASONRY: - return Docs.Create.MasonryDocument; - case LayoutType.CAROUSEL: - return Docs.Create.CarouselDocument; - default: - return Docs.Create.FreeformDocument; - } - }; - - const collection: Doc = collectionFactory()(this._fullyRenderedDocs, { - isDefaultTemplateDoc: true, - _height: verticalSpan, - _width: horizontalSpan, - title: 'title', - backgroundColor: 'gray', - }); - - this.applyLayout(collection, this._fullyRenderedDocs); - - this._renderedDocCollection = collection; - }; - - layoutPreviewContents = () => { - return this._docsRendering ? ( - <div className="docCreatorMenu-layout-preview-window-wrapper loading"> - <div className="loading-spinner"> - <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> - </div> - </div> - ) : !this._renderedDocCollection ? null : ( - <div className="docCreatorMenu-layout-preview-window-wrapper"> - <DocumentView - Document={this._renderedDocCollection} - isContentActive={emptyFunction} - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => this._menuDimensions.width - 80} - PanelHeight={() => this._menuDimensions.height - 105} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={5} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={this._props.addDocTab} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - hideDecorations={true} - /> - </div> - ); - }; - - get optionsMenuContents() { - const layoutOption = (option: LayoutType, optStyle?: object, specialFunc?: () => void) => { - return ( - <div - className="docCreatorMenu-dropdown-option" - style={optStyle} - onPointerDown={e => - this.setUpButtonClick(e, () => { - specialFunc?.(); - runInAction(() => { - this._layout.type = option; - this.updateRenderedDocCollection(); - }); - }) - }> - {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 IconProp} /> - </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]; - + get templatesView() { 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.FREEFORM, undefined, () => { - if (!this._layout.columns) this._layout.columns = Math.ceil(Math.sqrt(this.docsToRender.length)); - })} - {layoutOption(LayoutType.CAROUSEL)} - {layoutOption(LayoutType.CAROUSEL3D)} - {layoutOption(LayoutType.MASONRY)} + <div className="docCreatorMenu-templates-view"> + <div className="docCreatorMenu-templates-displays"> + <TemplatePreviewGrid title={'Suggested Templates'} menu={this} loading={this._GPTLoading} optionsButtonOpts={this.optionsButtonOpts} templates={this._suggestedTemplates} /> + <div className="docCreatorMenu-GPT-options"> + <div className="docCreatorMenu-GPT-options-container"> + <DocCreatorMenuButton icon={'arrows-rotate'} styles={'border'} function={this.generatePresetTemplates} /> </div> </div> </div> - {this._layout.type ? this.layoutConfigOptions : null} - {this.layoutPreviewContents()} - {selectionBox( - 60, - 20, - 'repeat', - undefined, - repeatOptions.map(num => <option key={num} onPointerDown={() => (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.getRenderedDoc(), - 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; - this.addRenderedCollectionToMainview(); - }, 'make docs') - ) - }> - <FontAwesomeIcon icon="plus" /> - </button> - </div> </div> ); } - get dashboardContents() { - const sizes: string[] = ['tiny', 'small', 'medium', 'large', 'huge']; - - const fieldPanel = (field: Col, id: number) => { - return ( - <div className="field-panel" key={id}> - <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.removeField(field))} 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)' }} value={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"> Prompt </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/prompt 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, i) => fieldPanel(field, i))}</div> - </div> - ); - } + private optionsButtonOpts: [IconProp, () => void] = ['gear', () => (this._menuContent = 'dashboard')]; get renderSelectedViewType() { switch (this._menuContent) { - case 'templates': - return this.templatesPreviewContents; - case 'options': - return this.optionsMenuContents; - case 'dashboard': - return this.dashboardContents; - default: - return undefined; - } + case 'templates': return this.templatesView; + case 'templateEditing': return <TemplateEditingWindow template={this._currEditingTemplate as Template} menu={this} />; + case 'renderPreview': return <TemplatesRenderPreviewWindow menu={this}/>; + case 'dashboard': return <TemplateMenuFieldOptions menu={this} templateManager={this.templateManager}/>; + } // prettier-ignore + return undefined; } get resizePanes() { @@ -1374,42 +540,26 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> ]; //prettier-ignore } + setRef = (r: HTMLDivElement) => (this._ref = r); render() { - const topButton = (icon: string, opt: string, func: () => void, 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 IconProp} /> - </div> + const topButton = (icon: string, opt: string, func: () => void, tag: string) => ( + <div className={`top-button-container ${tag} ${opt === this._menuContent ? 'selected' : ''}`}> + <div className="top-button-content" onPointerDown={e => this.setUpButtonClick(e, action(func))}> + <FontAwesomeIcon icon={icon as IconProp} /> </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)); - }; + const onPreviewSelected = () => (this._menuContent = 'templates'); + const onSavedSelected = () => (this._menuContent = 'dashboard'); + const onOptionsSelected = () => (this._menuContent = 'renderPreview'); return ( <div className="docCreatorMenu"> {!this._shouldDisplay ? undefined : ( <div className="docCreatorMenu-cont" - ref={r => (this._ref = r)} + ref={this.setRef} style={{ display: '', left: this._pageX, @@ -1435,9 +585,7 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> return true; }, emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - }, 'drag menu') + undoable(clickEv => clickEv.stopPropagation(), 'drag menu') ) }> <div className="docCreatorMenu-top-buttons-container"> @@ -1445,9 +593,7 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> {topButton('magnifying-glass', 'options', onOptionsSelected, 'middle')} {topButton('bars', 'saved', onSavedSelected, 'right')} </div> - <button className="docCreatorMenu-menu-button close-menu" onPointerDown={e => this.setUpButtonClick(e, this.closeMenu)}> - <FontAwesomeIcon icon={'minus'} /> - </button> + <DocCreatorMenuButton icon={'minus'} styles={'float-right'} function={this.closeMenu} /> </div> {this.renderSelectedViewType} </div> diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/DynamicField.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/DynamicField.tsx deleted file mode 100644 index c5254c17d..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/DynamicField.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { Docs } from "../../../../../documents/Documents"; -import { Field, FieldDimensions, FieldSettings, ViewType } from "./Field"; -import { FieldUtils } from "./FieldUtils"; -import { StaticField } from "./StaticField"; - -export class DynamicField implements Field { - private subfields: Field[] = []; - - private id: number; - private settings: FieldSettings; - private title: string = ''; - - private parent: Field; - private dimensions: FieldDimensions; - - constructor(settings: FieldSettings, id: number, parent?: Field) { - this.id = id; - this.settings = settings; - if (settings.title) { this.title = settings.title }; - if (!parent) { - this.parent = this; - this.dimensions = {width: this.settings.br[0] - this.settings.tl[0], height: this.settings.br[1] - this.settings.tl[1], coord: {x: this.settings.tl[0], y: this.settings.tl[1]}}; - } else { - this.parent = parent; - this.dimensions = FieldUtils.getLocalDimensions({tl: settings.tl, br: settings.br}, this.parent.getDimensions); - } - this.subfields = this.setupSubfields(); - } - - setContent = () => { return }; - getContent = () => { return '' }; - - setTitle = (title: string) => { this.title = title }; - getTitle = () => { return this.title }; - - get getSubfields() { return this.subfields }; - get getAllSubfields() { - let fields: Field[] = []; - this.subfields?.forEach(field => { - fields.push(field); - fields = fields.concat(field.getAllSubfields) - }); - return fields; - }; - - get getDimensions() { return this.dimensions }; - get getID() { return this.id }; - get getViewType() { return this.settings.viewType }; - - get getDescription(): string { - return this.settings.description ?? ''; - } - - matches = (): Array<number> => { - return []; - } - - updateRenderedDoc = () => { - return new Doc(); - } - - setupSubfields = (): Field[] => { - const fields: Field[] = []; - this.settings.subfields?.forEach((fieldSettings, index) => { - let field: Field; - const type = fieldSettings.viewType; - - const id = Number(String(this.id) + String(index)); - - if (type == ViewType.CAROUSEL3D || type === ViewType.FREEFORM) { - field = new DynamicField(fieldSettings, id, this); - } else { - field = new StaticField(fieldSettings, this, id); - } - fields.push(field); - }); - return fields; - } - - applyAttributes = (field: Field) => { - field.setTitle(this.title); - field.updateRenderedDoc(this.renderedDoc()); - } - - getChildDimensions = (coords: { tl: [number, number]; br: [number, number] }): FieldDimensions => { - const l = (coords.tl[0] * this.dimensions.height) / 2; - const t = coords.tl[1] * this.dimensions.width / 2; //prettier-ignore - const r = (coords.br[0] * this.dimensions.height) / 2; - const b = coords.br[1] * this.dimensions.width / 2; //prettier-ignore - const width = r - l; - const height = b - t; - const coord = { x: l, y: t }; - return { width, height, coord }; - }; - - renderedDoc = (): Doc => { - let doc: Doc; - switch (this.settings.viewType) { - case ViewType.CAROUSEL3D: - doc = Docs.Create.Carousel3DDocument(this.subfields.map(field => field.renderedDoc()), { - title: this.title, - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings); - return doc; - case ViewType.FREEFORM: - doc = Docs.Create.FreeformDocument(this.subfields.map(field => field.renderedDoc()), { - title: this.title, - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings); - return doc; - default: - return new Doc(); - } - } - -} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/Field.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/Field.tsx deleted file mode 100644 index ea9b566b3..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/Field.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { Col } from "../DocCreatorMenu"; -import { TemplateFieldSize, TemplateFieldType } from "../TemplateBackend"; - -export enum FieldContentType { - STRING = 'string', - IMAGE = 'image', -} - -export enum ViewType { - CAROUSEL3D = 'carousel3d', - FREEFORM = 'freeform', - STATIC = 'static', - DEC = 'decoration' -} - -export type FieldDimensions = { - width: number; - height: number; - coord: {x: number, y: number}; -} - -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; - fontBold?: boolean; - fontTransform?: 'uppercase' | 'lowercase'; - fieldViewType?: 'freeform' | 'stacked'; -} - -export type FieldSettings = { - tl: [number, number]; - br: [number, number]; - opts: FieldOpts; - viewType: ViewType; - title?: string; - subfields?: FieldSettings[]; - types?: TemplateFieldType[]; - sizes?: TemplateFieldSize[]; - description?: string; -}; - -export interface Field { - getContent: () => string; - setContent: (content: string, type?: FieldContentType) => void; - getDimensions: FieldDimensions; - getSubfields: Field[]; - getAllSubfields: Field[]; - getID: number; - getViewType: ViewType; - getDescription: string; - getTitle: () => string; - setTitle: (title: string) => void; - setupSubfields: () => Field[]; - applyAttributes: (field: Field) => void; - renderedDoc: () => Doc; - matches: (cols: Col[]) => number[]; - updateRenderedDoc: (oldDoc?: Doc) => Doc; -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/FieldUtils.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/FieldUtils.tsx deleted file mode 100644 index 3886774d2..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/FieldUtils.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { ComputedField, ScriptField } from "../../../../../../fields/ScriptField"; -import { Col } from "../DocCreatorMenu"; -import { TemplateFieldSize, TemplateFieldType, TemplateLayouts } from "../TemplateBackend"; -import { FieldDimensions, FieldSettings } from "./Field"; - -export class FieldUtils { - public static getLocalDimensions = (coords: { tl: [number, number]; br: [number, number] }, parentDimensions: FieldDimensions): FieldDimensions => { - const l = (coords.tl[0] * parentDimensions.width) / 2; - const t = coords.tl[1] * parentDimensions.height / 2; //prettier-ignore - const r = (coords.br[0] * parentDimensions.width) / 2; - const b = coords.br[1] * parentDimensions.height / 2; //prettier-ignore - const width = r - l; - const height = b - t; - const coord = { x: l, y: t }; - return { width, height, coord }; - }; - - public static applyBasicOpts = (doc: Doc, parentDimensions: FieldDimensions, settings: FieldSettings, oldDoc?: Doc) => { - const opts = settings.opts; - doc.isDefaultTemplateDoc = oldDoc ? oldDoc.isDefaultTemplateDoc : true; - doc._layout_hideScroll = oldDoc ? oldDoc._layout_hideScroll : true; - doc.x = oldDoc ? oldDoc.x : parentDimensions.coord.x; - doc.y = oldDoc ? oldDoc.y : parentDimensions.coord.y; - doc._height = oldDoc ? oldDoc.height : parentDimensions.height; - doc._width = oldDoc ? oldDoc.width : parentDimensions.width; - doc.backgroundColor = oldDoc ? oldDoc.backgroundColor : opts.backgroundColor ?? ''; - doc._layout_borderRounding = !opts.cornerRounding ? '0px' : ScriptField.MakeFunction(`${opts.cornerRounding} * this.width + 'px'`); - doc.borderColor = oldDoc ? oldDoc.borderColor : opts.borderColor; - doc.borderWidth = oldDoc ? oldDoc.borderWidth : opts.borderWidth; - doc.opacity = oldDoc ? oldDoc.opacity : opts.opacity; - doc._rotation = oldDoc ? oldDoc._rotation : opts.rotation; - doc.hCentering = oldDoc ? oldDoc.hCentering : opts.contentXCentering; - doc.nativeWidth = parentDimensions.width; - doc.nativeHeight = parentDimensions.height; - doc._layout_nativeDimEditable = true; - }; - - 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.7; - - 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; - - currFontSize += 1; - } - - return currFontSize - 1; - }; -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/StaticField.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/StaticField.tsx deleted file mode 100644 index 47b43f051..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/StaticField.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { Docs } from "../../../../../documents/Documents"; -import { Col } from "../DocCreatorMenu"; -import { DynamicField } from "./DynamicField"; -import { FieldUtils } from "./FieldUtils"; -import { Field, FieldContentType, FieldDimensions, FieldSettings, ViewType } from "./Field"; - -export class StaticField { - private content: string; - private contentType: FieldContentType | undefined; - private subfields: Field[] = []; - private renderedDocument: Doc; - - private id: number; - private title: string = ''; - - private settings: FieldSettings; - - private parent: Field; - private dimensions: FieldDimensions; - - constructor(settings: FieldSettings, parent: Field, id: number) { - this.settings = settings; - if (settings.title) { this.title = settings.title }; - this.id = id; - this.parent = parent; - this.dimensions = FieldUtils.getLocalDimensions({tl: settings.tl, br: settings.br}, this.parent.getDimensions); - this.content = ''; - this.subfields = this.setupSubfields(); - this.renderedDocument = this.updateRenderedDoc(); - }; - - get getSubfields(): Field[] { return this.subfields ?? []; }; - - get getAllSubfields(): Field[] { - let fields: Field[] = []; - this.subfields?.forEach(field => { - fields.push(field); - fields = fields.concat(field.getAllSubfields); - }); - return fields; - }; - - get getDimensions() { return this.dimensions }; - get getID() { return this.id }; - get getViewType() { return this.settings.viewType }; - - get getDescription(): string { - return this.settings.description ?? ''; - } - - renderedDoc = () => { - return this.renderedDocument; - } - - setContent = (newContent: string, type?: FieldContentType) => { - this.content = newContent; - if (type) this.contentType = type; - this.updateRenderedDoc(this.renderedDocument); - }; - getContent() { return this.content }; - - setTitle = (title: string) => { - this.title = title; - this.renderedDocument.title = title; - this.updateRenderedDoc(this.renderedDocument); - }; - getTitle = () => { return this.title }; - - applyAttributes = (field: Field) => { //!!! can be updated later for more robust clonign; this is all ythat's needed now - field.setTitle(this.title); - field.setContent('', this.contentType); - field.updateRenderedDoc(this.renderedDoc()); - } - - setupSubfields = (): Field[] => { - const fields: Field[] = []; - this.settings.subfields?.forEach((fieldSettings, index) => { - let field: Field; - const type = fieldSettings.viewType; - - const id = Number(String(this.id) + String(index)); - - if (type === ViewType.FREEFORM || type === ViewType.CAROUSEL3D) { - field = new DynamicField(fieldSettings, id, this); - } else { - field = new StaticField(fieldSettings, this, id); - }; - - fields.push(field); - }); - return fields; - }; - - matches = (cols: Col[]): number[] => { - const colMatchesField = (col: Col) => { - const isMatch: boolean = ( - this.settings.sizes?.some(size => col.sizes?.includes(size)) - && this.settings.types?.includes(col.type)) - ?? false; - return isMatch; - } - - const matches: Array<number> = []; - - cols.forEach((col, v) => { - if (colMatchesField(col)) { - matches.push(v); - } - }); - - return matches; - }; - - updateRenderedDoc = (oldDoc?: Doc): Doc => { - const opts = this.settings.opts; - - if (!this.contentType) { this.contentType = FieldContentType.STRING }; - - let doc: Doc; - - switch (this.contentType) { - case FieldContentType.STRING: - doc = Docs.Create.TextDocument(String(this.content), { - title: this.title, - text_fontColor: oldDoc ? String(oldDoc.color) : opts.color, - contentBold: oldDoc ? Boolean(oldDoc.fontBold) : opts.fontBold, - textTransform: oldDoc ? String(oldDoc.fontTransform) : opts.fontTransform, - color: oldDoc ? String(oldDoc.color) : opts.color, - _text_fontSize: `${FieldUtils.calculateFontSize(this.dimensions.width, this.dimensions.height, String(this.content), true)}` - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings, oldDoc); - break; - case FieldContentType.IMAGE: - doc = Docs.Create.ImageDocument(String(this.content), { - title: this.title, - _layout_fitWidth: false, - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings, oldDoc); - break; - } - - this.renderedDocument = doc; - - return doc; - }; -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/ConditionalsTextarea.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/ConditionalsTextarea.tsx new file mode 100644 index 000000000..89c2e44ff --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/ConditionalsTextarea.tsx @@ -0,0 +1,65 @@ +import { observer } from "mobx-react"; +import { ObservableReactComponent } from "../../../../ObservableReactComponent"; +import { Conditional } from "../Backend/TemplateManager"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import React from "react"; + +interface ConditionalsTextAreaProps { + conditional: Conditional; + property: keyof Conditional; +} + +@observer +export class ConditionalsTextArea extends ObservableReactComponent<ConditionalsTextAreaProps> { + + private mirrorRef: HTMLSpanElement | null = null; + + @observable private inputWidth: string = '60px'; + + constructor(props: ConditionalsTextAreaProps) { + super(props); + makeObservable(this); + } + + setMirrorRef: React.LegacyRef<HTMLSpanElement> = (node) => { this.mirrorRef = node } + + @action updateInputWidth() { + const mirror = this.mirrorRef; + if (mirror) { + const width = mirror.offsetWidth; + if ( width + 8 > 60) this.inputWidth = `${width + 8}px`; + } + } + + render() { + return ( + <div style={{ display: 'inline-block', position: 'relative' }}> + <span + ref={this.setMirrorRef} + style={{ + position: 'absolute', + visibility: 'hidden', + whiteSpace: 'pre', + font: 'inherit', + padding: 0, + }} + > + {this._props.conditional[this._props.property] || ' '} + </span> + <input + className="form-row-input" + value={this.props.conditional[this.props.property] ?? ''} + onChange={e => { + runInAction(() => { + this.props.conditional[this.props.property] = e.target.value as "=" | ">" | "<" | "contains"; + }); + this.updateInputWidth(); + }} + style={{ width: this.inputWidth }} + placeholder={this.props.property} + /> + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/DocCreatorMenuButton.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/DocCreatorMenuButton.tsx new file mode 100644 index 000000000..48d2de4de --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/DocCreatorMenuButton.tsx @@ -0,0 +1,42 @@ +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { ObservableReactComponent } from "../../../../ObservableReactComponent"; +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { setupMoveUpEvents, returnFalse } from "../../../../../../ClientUtils"; +import { emptyFunction } from "../../../../../../Utils"; +import { undoable } from "../../../../../util/UndoManager"; +import { observer } from "mobx-react"; + +interface DocCreatorMenuButtonProps { + icon: IconProp; + // eslint-disable-next-line + function: () => any; + styles?: string; +} + +@observer +export class DocCreatorMenuButton extends ObservableReactComponent<DocCreatorMenuButtonProps> { + // eslint-disable-next-line + setupButtonClick = (e: React.PointerEvent, func: (...args: any) => void) => { + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + clickEv.preventDefault(); + func(); + }, 'create docs') + ); + }; + + render() { + + return ( + <button className={`docCreatorMenu-menu-button ${this._props.styles}`} onPointerDown={e => this.setupButtonClick(e, async () => this._props.function())}> + <FontAwesomeIcon icon={this._props.icon} /> + </button> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateEditingWindow.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateEditingWindow.tsx new file mode 100644 index 000000000..c35099e82 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateEditingWindow.tsx @@ -0,0 +1,220 @@ +import { action, makeObservable, observable, reaction } from 'mobx'; +import React from 'react'; +import { returnFalse, returnEmptyFilter } from '../../../../../../ClientUtils'; +import { emptyFunction } from '../../../../../../Utils'; +import { returnEmptyDoclist } from '../../../../../../fields/Doc'; +import { DefaultStyleProvider } from '../../../../StyleProvider'; +import { DocumentView, DocumentViewInternal } from '../../../DocumentView'; +import { DocCreatorMenu } from '../DocCreatorMenu'; +import { TemplatePreviewGrid } from './TemplatePreviewGrid'; +import { observer } from 'mobx-react'; +import { Transform } from '../../../../../util/Transform'; +import { Template } from '../Template'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { IDisposer } from 'mobx-utils'; +import { DocCreatorMenuButton } from './DocCreatorMenuButton'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; + +export type FireflyStructureOptions = { + numVariations: number; + temperature: number; + useStyleRef: boolean; +}; + +interface FireflyVariationsTabProps { + menu: DocCreatorMenu; + template: Template; +} + +@observer +export class FireflyVariationsTab extends ObservableReactComponent<FireflyVariationsTabProps> { + private _prompt: string = 'Use this template to generate an empty baseball card template.'; + private _optionsButtonOpts: [IconProp, () => void] = ['gear', emptyFunction]; + private _previewBoxRightButtonOpts: [IconProp, () => void] = ['gear', () => this.forceUpdate()]; + + @observable _fireflyOptions: FireflyStructureOptions = { numVariations: 3, temperature: 0, useStyleRef: false }; + @observable _promptInput: HTMLTextAreaElement | null = null; + @observable _loading: boolean = false; + @observable _variationsTabOpen: boolean = false; + @observable _variationURLs: string[] = []; + + constructor(props: FireflyVariationsTabProps) { + super(props); + makeObservable(this); + } + + generateVariations = action(async () => { + this._props.menu._variations = []; + this._loading = true; + const cloneTemplate = this._props.template.clone(false); + cloneTemplate.setMatteBackground(); + const doc = cloneTemplate.getRenderedDoc()!; + this._props.menu.generateVariations(doc, this._prompt, this._fireflyOptions).then( + action((urls: string[]) => { + (this._variationURLs = urls).forEach(url => { + const template = this._props.template.clone(true); + template.setImageAsBackground(url, true); + this._props.menu._variations.push(template); + }); + this._loading = false; + }) + ); + }); + + render() { + return ( + <div className="docCreatorMenu-editing-firefly-section"> + <div className="docCreatorMenu-option-divider full no-margin-bottom" /> + <TemplatePreviewGrid + menu={this._props.menu} + title="Generate Variations" + loading={this._loading} + styles="scrolling" + templates={this._props.menu._variations} + optionsButtonOpts={this._optionsButtonOpts} + previewBoxRightButtonOpts={this._previewBoxRightButtonOpts} + /> + <div className="docCreatorMenu-firefly-options"> + <div className="docCreatorMenu-variation-prompt-row"> + <textarea + className="docCreatorMenu-variation-prompt-input-textbox" + ref={action((node: HTMLTextAreaElement | null) => (this._promptInput = node))} + onChange={e => (this._prompt = e.target.value)} + onInput={() => { + if (this._promptInput !== null) { + this._promptInput.style.height = 'auto'; + this._promptInput.style.height = this._promptInput.scrollHeight + 'px'; + } + }} + defaultValue="" + placeholder="Enter a custom prompt here (optional)" + /> + <DocCreatorMenuButton icon={'arrows-rotate'} styles={'border'} function={this.generateVariations} /> + </div> + <nav className="options‑menu"> + <label className="menu‑item switch"> + <input type="checkbox" checked={this._fireflyOptions.useStyleRef} onChange={action(e => (this._fireflyOptions.useStyleRef = e.target.checked))} /> + <span className="slider round"></span> + <span className="firefly-option-label">Use template as style guide</span> + </label> + <div className="menu‑item"> + <span className="firefly-option-label">Variations</span> + <input type="range" id="variations" min="1" max="5" value={this._fireflyOptions.numVariations} onChange={action(e => (this._fireflyOptions.numVariations = Number(e.target.value)))} /> + <span className="value" id="varVal"> + {this._fireflyOptions.numVariations} + </span> + </div> + <div className="menu‑item"> + <span className="firefly-option-label">Temperature</span> + <input type="range" id="temperature" min="1" max="100" value={this._fireflyOptions.temperature} onChange={action(e => (this._fireflyOptions.temperature = Number(e.target.value)))} /> + <span className="value" id="tempVal"> + {this._fireflyOptions.temperature} + </span> + </div> + </nav> + </div> + </div> + ); + } +} + +interface TemplateEditingWindowProps { + menu: DocCreatorMenu; + template: Template; +} + +@observer +export class TemplateEditingWindow extends ObservableReactComponent<TemplateEditingWindowProps> { + private disposers: { [name: string]: IDisposer } = {}; + + @observable private _previewWindow: HTMLDivElement | null = null; + @observable _variationsTabOpen: boolean = false; + + constructor(props: TemplateEditingWindowProps) { + super(props); + makeObservable(this); + } + + componentDidMount(): void { + this.disposers.windowDimensions = reaction( + () => this._props.menu._resizing, + () => this.forceUpdate(), + { fireImmediately: true } + ); + } + + componentWillUnmount() { + Object.values(this.disposers).forEach(disposer => disposer?.()); + } + + @action setVariationTab = (open: boolean) => { + this._variationsTabOpen = open; + if (this._previewWindow && open) { + this._previewWindow.style.height = String(Number(this._previewWindow.clientHeight) * 0.6); + } else if (this._previewWindow && !open) { + this._previewWindow.style.height = String((Number(this._previewWindow.clientHeight) * 5) / 3); + } + }; + + previewPanelWidth = () => this._previewWindow?.clientWidth ?? 500; + previewPanelHeight = () => this._previewWindow?.clientHeight ?? 500; + previewScreenToLocalXf = () => new Transform(-this._props.menu._pageX - 5, -this._props.menu._pageY - 35, 1); + get renderedDocPreview() { + const doc = this._props.template.getRenderedDoc(); + return ( + <div className="docCreatorMenu-expanded-template-preview" ref={action((node: HTMLDivElement | null) => (this._previewWindow = node))}> + {this._previewWindow && doc ? ( + <DocumentView + Document={doc} + isContentActive={emptyFunction} + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + PanelWidth={this.previewPanelWidth} + PanelHeight={this.previewPanelHeight} + ScreenToLocalTransform={this.previewScreenToLocalXf} + renderDepth={5} + whenChildContentsActiveChanged={emptyFunction} + focus={emptyFunction} + styleProvider={DefaultStyleProvider} + addDocTab={DocumentViewInternal.addDocTabFunc} + pinToPres={emptyFunction} + childFilters={returnEmptyFilter} + childFiltersByRanges={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + // fitContentsToBox={returnFalse} + // fitWidth={returnFalse} + /> + ) : null} + </div> + ); + } + + expandFunc = () => { + // if (this._props.template === this._props.menu._selectedTemplate) { + // this._props.menu.updateRenderedPreviewCollection(this._props.template); + // } + this._props.menu.setExpandedView(undefined); + }; + lastFunc = () => { + this._props.menu.editLastTemplate(); + this.forceUpdate(); + }; + variationFunc = () => this.setVariationTab(!this._variationsTabOpen); + render() { + return ( + <div className="docCreatorMenu-templates-view"> + <div className="docCreatorMenu-expanded-template-preview"> + <div className="top-panel" /> + {this.renderedDocPreview} + {this._variationsTabOpen ? <FireflyVariationsTab menu={this._props.menu} template={this._props.template} /> : null} + <div className="right-buttons-panel"> + <DocCreatorMenuButton icon="minimize" function={this.expandFunc} /> + <DocCreatorMenuButton icon="lightbulb" function={this.variationFunc} /> + <DocCreatorMenuButton icon="arrow-rotate-backward" function={this.lastFunc} /> + </div> + </div> + </div> + ); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateMenuFieldOptions.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateMenuFieldOptions.tsx new file mode 100644 index 000000000..f0e20837c --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateMenuFieldOptions.tsx @@ -0,0 +1,185 @@ +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { Col, DocCreatorMenu } from '../DocCreatorMenu'; +import React from 'react'; +import { Conditional, TemplateManager } from '../Backend/TemplateManager'; +import { TemplateFieldType, TemplateFieldSize } from '../TemplateBackend'; +import { DocCreatorMenuButton } from './DocCreatorMenuButton'; + +interface TemplateMenuFieldOptionsProps { + menu: DocCreatorMenu; + templateManager: TemplateManager; +} + +@observer +export class TemplateMenuFieldOptions extends ObservableReactComponent<TemplateMenuFieldOptionsProps> { + @observable _collapsedCols: string[] = []; //any columns whose options panels are hidden + + constructor(props: TemplateMenuFieldOptionsProps) { + super(props); + makeObservable(this); + } + + @observable private _newCondCache: Record<string, Conditional> = {}; + + getParams = (title: string, parameters?: Conditional): Conditional => { + if (parameters) return parameters; + + if (!this._newCondCache[title]) { + this._newCondCache[title] = observable<Conditional>({ + field: title, + operator: '=', + condition: '', + target: 'Own', + attribute: '', + value: '', + }); + } + return this._newCondCache[title]; + }; + + conditionForm = (title: string, parameters?: Conditional, empty: boolean = false) => { + const contentFieldTitles = this._props.menu.fieldsInfos + .filter(field => field.type !== TemplateFieldType.DATA) + .map(field => field.title) + .concat('Template'); + const params: Conditional = this.getParams(title, parameters); + + return ( + <div className="form"> + <div className="form-row"> + <div className="form-row-plain-text">If</div> + <div className="form-row-plain-text">{title}</div> + <div className="operator-options-dropdown"> + <span className="operator-dropdown-current">{params.operator ?? '='}</span> + <div className="operator-dropdown-option" onPointerDown={() => (params.operator = '=')}> + {'='} + </div> + </div> + <input className="form-row-textarea" onChange={action(e => (params.condition = e.target.value))} placeholder="value" value={params.condition} /> + <div className="form-row-plain-text">then</div> + <div className="operator-options-dropdown"> + <span className="operator-dropdown-current">{params.target ?? 'Own'}</span> + {contentFieldTitles.map((fieldTitle, i) => ( + <div className="operator-dropdown-option" key={i} onPointerDown={() => (params.target = fieldTitle)}> + {fieldTitle === title ? 'Own' : fieldTitle} + </div> + ))} + </div> + <input className="form-row-textarea" onChange={action(e => (params.attribute = e.target.value))} placeholder="attribute" value={params.attribute} /> + <div className="form-row-plain-text">{'becomes'}</div> + <input className="form-row-textarea" onChange={action(e => (params.value = e.target.value))} placeholder="value" value={params.value} /> + </div> + {empty ? ( + <DocCreatorMenuButton + icon={'plus'} + styles={'float-right border'} + function={() => { + this._newCondCache[title] = observable<Conditional>({ + field: title, + operator: '=', + condition: '', + target: 'Own', + attribute: '', + value: '', + }); + this._props.templateManager.addFieldCondition(title, params); + }} + /> + ) : ( + <DocCreatorMenuButton icon={'minus'} styles={'float-right border'} function={() => this._props.templateManager.removeFieldCondition(title, params)} /> + )} + </div> + ); + }; + + fieldPanel = (field: Col, id: number) => ( + <div className="field-panel" key={id}> + <div + className="top-bar" + onPointerDown={e => + this._props.menu.setUpButtonClick( + e, + action(() => { + if (this._collapsedCols.includes(field.title)) { + this._collapsedCols = this._collapsedCols.filter(col => col !== field.title); + } else { + this._collapsedCols.push(field.title); + } + }) + ) + }> + <span className="field-title">{`${field.title} Field`}</span> + <DocCreatorMenuButton icon={'minus'} styles={'no-margin absolute-right'} function={() => this._props.menu.removeField(field)} /> + </div> + {this._collapsedCols.includes(field.title) ? null : ( + <> + <div className="opts-bar"> + <div className="opt-box"> + <div className="top-bar"> Title </div> + <textarea className="content" style={{ width: '100%', height: 'calc(100% - 20px)' }} value={field.title} placeholder={'Enter title'} onChange={e => this._props.menu.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' : field.type === TemplateFieldType.DATA ? 'Data Field' : ''}</span> + <div className="bubbles"> + <input className="bubble" type="radio" name="type" onClick={() => this._props.menu.setColType(field, TemplateFieldType.TEXT)} /> + <div className="text">Text</div> + <input className="bubble" type="radio" name="type" onClick={() => this._props.menu.setColType(field, TemplateFieldType.VISUAL)} /> + <div className="text">File</div> + <input className="bubble" type="radio" name="type" onClick={() => this._props.menu.setColType(field, TemplateFieldType.DATA)} /> + <div className="text">Data</div> + </div> + </div> + </div> + </div> + {field.type === TemplateFieldType.DATA ? null : ( + <> + <div className="sizes-box"> + <div className="top-bar"> Valid Sizes </div> + <div className="content"> + <div className="bubbles"> + {Object.values(TemplateFieldSize).map(size => ( + <div key={field + size}> + <input className="bubble" type="checkbox" name="type" checked={field.sizes.includes(size)} onChange={e => this._props.menu.modifyColSizes(field, size, e.target.checked)} /> + <div className="text">{size}</div> + </div> + ))} + </div> + </div> + </div> + <div className="desc-box"> + <div className="top-bar"> Prompt </div> + <textarea + className="content" + onChange={e => this._props.menu.setColDesc(field, e.target.value)} + defaultValue={field.desc === this._props.menu._dataViz?.GPTSummary?.get(field.title)?.desc ? '' : field.desc} + placeholder={this._props.menu._dataViz?.GPTSummary?.get(field.title)?.desc ?? 'Add a description/prompt to help with template generation.'} + /> + </div> + </> + )} + <div className="conditionals-section"> + <span className="conditionals-title">Conditional Logic</span> + {this.conditionForm(field.title, undefined, true)} + {this._props.templateManager._conditionalFieldLogic[field.title]?.map(condition => this.conditionForm(condition.field, condition))} + </div> + </> + )} + </div> + ); + + render() { + return ( + <div className="docCreatorMenu-dashboard-view"> + <div className="topbar"> + <DocCreatorMenuButton icon="plus" function={this._props.menu.addField} /> + <DocCreatorMenuButton icon="arrow-left" styles="float-right" function={action(() => (this._props.menu._menuContent = 'templates'))} /> + </div> + <div className="panels-container">{this._props.menu.fieldsInfos.map((field, i) => this.fieldPanel(field, i))}</div> + </div> + ); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewBox.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewBox.tsx new file mode 100644 index 000000000..7d02fff12 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewBox.tsx @@ -0,0 +1,89 @@ +import { Colors } from '@dash/components/src'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Template } from '../Template'; +import { action, makeObservable, observable } from 'mobx'; +import React from 'react'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { DocCreatorMenu } from '../DocCreatorMenu'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { DocumentView } from '../../../DocumentView'; +import { emptyFunction } from '../../../../../../Utils'; +import { returnEmptyFilter, returnFalse } from '../../../../../../ClientUtils'; +import { Transform } from '../../../../../util/Transform'; +import { DefaultStyleProvider } from '../../../../StyleProvider'; +import { Doc, returnEmptyDoclist } from '../../../../../../fields/Doc'; +import { observer } from 'mobx-react'; + +export interface TemplatePreviewBoxProps { + template: Template; + menu: DocCreatorMenu; // eslint-disable-next-line + leftButtonOpts?: [icon: IconProp, func: (...args: any) => void]; // eslint-disable-next-line + rightButtonOpts?: [icon: IconProp, func: (...args: any) => void]; +} + +@observer +export class TemplatePreviewBox extends ObservableReactComponent<TemplatePreviewBoxProps> { + @observable private previewWindow: HTMLDivElement | null = null; + + constructor(props: TemplatePreviewBoxProps) { + super(props); + makeObservable(this); + } + + get doc() { + return this._props.template.getRenderedDoc() as Doc; + } + + docPanelWidth = () => this.previewWindow?.clientWidth ?? this._props.menu._menuDimensions.height * 0.3; + docPanelHeight = () => this.previewWindow?.clientHeight ?? this._props.menu._menuDimensions.height * 0.3; + docScreenToLocalXf = () => new Transform(-this._props.menu._pageX - 5, -this._props.menu._pageY - 35, 1); + + render() { + const template = this._props.template; + + return ( + <div + key={template.title} + className="docCreatorMenu-preview-window" + ref={action((node: HTMLDivElement | null) => (this.previewWindow = node))} + style={{ + border: this._props.menu._selectedTemplate === template ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', + boxShadow: this._props.menu._selectedTemplate === template ? `0 0 15px rgba(68, 118, 247, .8)` : '', + }} + onPointerDown={e => this._props.menu.setUpButtonClick(e, () => this._props.menu.updateSelectedTemplate(template))}> + {this._props.leftButtonOpts ? ( + <button className="option-button left" onPointerDown={e => this._props.menu.setUpButtonClick(e, () => this._props.leftButtonOpts)}> + <FontAwesomeIcon icon={this._props.leftButtonOpts![0]} color="white" /> + </button> + ) : null} + {this._props.rightButtonOpts ? ( + <button className="option-button right" onPointerDown={e => this._props.menu.setUpButtonClick(e, () => this._props.rightButtonOpts)}> + <FontAwesomeIcon icon={this._props.rightButtonOpts![0]} color="white" /> + </button> + ) : null} + <DocumentView + Document={this.doc} + isContentActive={emptyFunction} // !!! should be return false + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + PanelWidth={this.docPanelWidth} + PanelHeight={this.docPanelHeight} + ScreenToLocalTransform={this.docScreenToLocalXf} + renderDepth={1} + whenChildContentsActiveChanged={emptyFunction} + focus={emptyFunction} + styleProvider={DefaultStyleProvider} + addDocTab={this._props.menu._props.addDocTab} + pinToPres={emptyFunction} + childFilters={returnEmptyFilter} + childFiltersByRanges={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + // fitContentsToBox={returnFalse} + // fitWidth={returnFalse} + hideDecorations={true} + /> + </div> + ); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewGrid.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewGrid.tsx new file mode 100644 index 000000000..da4851f84 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewGrid.tsx @@ -0,0 +1,60 @@ +import { makeObservable, runInAction } from "mobx"; +import React from "react"; +import ReactLoading from "react-loading"; +import { Doc } from "../../../../../../fields/Doc"; +import { StrCast } from "../../../../../../fields/Types"; +import { ObservableReactComponent } from "../../../../ObservableReactComponent"; +import { Template } from "../Template"; +import { observer } from "mobx-react"; +import { DocCreatorMenu } from "../DocCreatorMenu"; +import { TemplatePreviewBox } from "./TemplatePreviewBox"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { DocCreatorMenuButton } from "./DocCreatorMenuButton"; + +export interface SuggestedTemplatesProps { + menu: DocCreatorMenu; + loading?: boolean; + templates: Template[]; + title: string; + styles?: string; // eslint-disable-next-line + optionsButtonOpts?: [IconProp, (...args: any) => any]; // eslint-disable-next-line + previewBoxLeftButtonOpts?: [IconProp, (...args: any) => any]; // eslint-disable-next-line + previewBoxRightButtonOpts?: [IconProp, (...args: any) => any]; +} + +@observer +export class TemplatePreviewGrid extends ObservableReactComponent<SuggestedTemplatesProps> { + + constructor(props: SuggestedTemplatesProps) { + super(props); + makeObservable(this); + } + + render() { + return ( + <div className="docCreatorMenu-section"> + <div className="docCreatorMenu-section-topbar"> + <div className="docCreatorMenu-section-title">{this.props.title}</div> + {this._props.optionsButtonOpts ? + <DocCreatorMenuButton icon={this._props.optionsButtonOpts[0] as IconProp} styles={'float-right'} function={() => runInAction(this._props.optionsButtonOpts![1])}/> + : null} + </div> + <div className={"docCreatorMenu-templates-preview-window " + this._props.styles}> + {this._props.loading ? + (<div className="loading-spinner"> + <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> + </div>) + : this.props.templates.map((template, i) => ( + <TemplatePreviewBox + key={i} + template={template} + menu={this.props.menu} + leftButtonOpts={["magnifying-glass", (template: Template) => { this.props.menu.setExpandedView(template); this.forceUpdate(); }]} + rightButtonOpts={this._props.previewBoxRightButtonOpts} + /> + ))} + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateRenderPreviewWindow.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateRenderPreviewWindow.tsx new file mode 100644 index 000000000..9222d7349 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateRenderPreviewWindow.tsx @@ -0,0 +1,346 @@ +import { action, computed, makeObservable, observable, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { DocCreatorMenu, LayoutType } from '../DocCreatorMenu'; +import React from 'react'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { setupMoveUpEvents, returnFalse, returnEmptyFilter } from '../../../../../../ClientUtils'; +import { emptyFunction } from '../../../../../../Utils'; +import { undoable } from '../../../../../util/UndoManager'; +import ReactLoading from 'react-loading'; +import { Doc, NumListCast, returnEmptyDoclist } from '../../../../../../fields/Doc'; +import { NumCast, StrCast } from '../../../../../../fields/Types'; +import { DefaultStyleProvider } from '../../../../StyleProvider'; +import { DocumentView } from '../../../DocumentView'; +import { Transform } from '../../../../../util/Transform'; +import { Docs, DocumentOptions } from '../../../../../documents/Documents'; + +interface TemplatesRenderPreviewWindowProps { + menu: DocCreatorMenu; +} + +@observer +export class TemplatesRenderPreviewWindow extends ObservableReactComponent<TemplatesRenderPreviewWindowProps> { + @observable private _layout: { type: LayoutType; yMargin: number; xMargin: number; columns?: number; repeat: number } = { type: LayoutType.FREEFORM, yMargin: 10, xMargin: 10, columns: 1, repeat: 0 }; + + @observable private renderedDocs: Doc[] = []; + @observable private renderedDocCollection: Doc | undefined = undefined; + + @observable private loading: boolean = false; + + constructor(props: TemplatesRenderPreviewWindowProps) { + super(props); + makeObservable(this); + this.updateRenderedPreviewCollection(); + } + + @computed get canMakeDocs() { + return this._props.menu._selectedTemplate !== undefined && this._layout !== undefined; + } + + @computed get docsToRender() { + if (this._props.menu.DEBUG_MODE) { + return [1, 2, 3, 4]; + } else { + return NumListCast(this._props.menu._dataViz?.layoutDoc.dataViz_selectedRows); + } + } + + @computed get rowsCount() { + switch (this._layout.type) { + case LayoutType.FREEFORM: + return Math.ceil(this.docsToRender.length / (this._layout.columns ?? 1)) ?? 0; + case LayoutType.CAROUSEL3D: + return 1.8; + default: + return 1; + } + } + + @computed get columnsCount() { + switch (this._layout.type) { + case LayoutType.FREEFORM: + return this._layout.columns ?? 1; + case LayoutType.CAROUSEL3D: + return 3; + default: + return 1; + } + } + + @action updateRenderedPreviewCollection = async () => { + this.loading = true; + this.renderedDocs = await this._props.menu.createDocsForPreview(); + this.updateRenderedDocCollection(); + }; + + /** + * Updates the preview that shows how all docs will be rendered in the chosen collection type. + @type the type of collection the docs should render to (ie. freeform, carousel, card) + */ + updateRenderedDocCollection = () => { + if (!this.renderedDocs) return; + + const collectionFactory = (): ((docs: Doc[], options: DocumentOptions) => Doc) => { + switch (this._layout.type) { + case LayoutType.CAROUSEL3D: return Docs.Create.Carousel3DDocument; + case LayoutType.FREEFORM: return Docs.Create.FreeformDocument; + case LayoutType.CARD: return Docs.Create.CardDeckDocument; + case LayoutType.MASONRY: return Docs.Create.MasonryDocument; + case LayoutType.CAROUSEL: return Docs.Create.CarouselDocument; + default: return Docs.Create.FreeformDocument; + } // prettier-ignore + }; + + const collection = collectionFactory()(this.renderedDocs, { + isDefaultTemplateDoc: true, + title: 'title', + backgroundColor: 'gray', + x: 200, + y: 200, + _width: 4000, + _height: 4000, + }); + + this.applyLayout(collection, this.renderedDocs); + + this.renderedDocCollection = collection; + + this.loading = false; + + this.forceUpdate(); + }; + + @action updateMargin = (input: string, xOrY: 'x' | 'y') => { + this._layout[`${xOrY}Margin`] = Number(input); + setTimeout(() => { + if (!this.renderedDocCollection || !this.renderedDocs) return; + this.applyLayout(this.renderedDocCollection, this.renderedDocs); + }); + }; + + @action updateColumns = (input: string) => { + this._layout.columns = Number(input); + this.updateRenderedDocCollection(); + }; + + applyLayout = (collection: Doc, docs: Doc[]) => { + const { horizontalSpan, verticalSpan } = this.previewInfo; + collection._height = verticalSpan; + collection._width = horizontalSpan; + collection.layout_fitWidth = true; + collection.freeform_fitContentsToBox = true; + + const columns = (this._layout.columns ?? this.columnsCount) || 1; + const xGap = this._layout.xMargin; + const yGap = this._layout.yMargin; + const startX = -collection._width / 2; + const startY = -collection._height / 2; + const docHeight = NumCast(docs[0]?._height); + const docWidth = NumCast(docs[0]?._width); + + let i = 0; + let docsChanged = 0; + let curX = startX; + let curY = startY; + + while (docsChanged < docs.length) { + while (i < columns && docsChanged < docs.length) { + docs[docsChanged].x = curX; + docs[docsChanged].y = curY; + docs[docsChanged].layout_fitWidth = false; + curX += docWidth + xGap; + ++docsChanged; + ++i; + } + i = 0; + curX = startX; + curY += docHeight + yGap; + } + }; + + @computed + get previewInfo() { + const docHeight = NumCast(this.renderedDocs[0]?._height); + const docWidth = NumCast(this.renderedDocs[0]?._width); + const layout = this._layout; + return { + docHeight: docHeight, + docWidth: docWidth, + horizontalSpan: (docWidth + layout.xMargin) * this.columnsCount - layout.xMargin, + verticalSpan: (docHeight + layout.yMargin) * this.rowsCount - layout.yMargin, + }; + } + + get layoutConfigOptions() { + const optionInput = (icon: string, func: (input: string) => void, 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 IconProp} /> + </div> + <input defaultValue={def} onInput={e => func(e.currentTarget.value)} className="docCreatorMenu-input config layout-config" /> + </div> + ); + }; + + switch (this._layout.type) { + case LayoutType.FREEFORM: + return ( + <div className="docCreatorMenu-configuration-bar"> + {optionInput('arrows-up-down', (input: string) => this.updateMargin(input, 'y'), this._layout.xMargin, '2')} + {optionInput('arrows-left-right', (input: string) => this.updateMargin(input, 'x'), this._layout.xMargin, '3')} + {optionInput('table-columns', this.updateColumns, this._layout.columns, '4', true)} + </div> + ); + default: + break; + } + } + + layoutPanelWidth = () => this._props.menu._menuDimensions.width - 80; + layoutPanelHeight = () => this._props.menu._menuDimensions.height - 105; + layoutScreenToLocalXf = () => new Transform(-this._props.menu._pageX - 5, -this._props.menu._pageY - 35, 1); + + layoutPreviewContents = action(() => { + return this.loading ? ( + <div className="docCreatorMenu-layout-preview-window-wrapper loading"> + <div className="loading-spinner"> + <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> + </div> + </div> + ) : !this.renderedDocCollection ? null : ( + <div className="docCreatorMenu-layout-preview-window-wrapper"> + <DocumentView + Document={this.renderedDocCollection} + isContentActive={emptyFunction} + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + PanelWidth={this.layoutPanelWidth} + PanelHeight={this.layoutPanelHeight} + ScreenToLocalTransform={this.layoutScreenToLocalXf} + renderDepth={5} + whenChildContentsActiveChanged={emptyFunction} + focus={emptyFunction} + styleProvider={DefaultStyleProvider} + addDocTab={this._props.menu._props.addDocTab} + pinToPres={emptyFunction} + childFilters={returnEmptyFilter} + childFiltersByRanges={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + hideDecorations={true} + /> + </div> + ); + }); + + 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 IconProp} /> + </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> + ); + }; + + layoutOption = (option: LayoutType, optStyle?: object, specialFunc?: () => void) => { + return ( + <div + className="docCreatorMenu-dropdown-option" + style={optStyle} + onPointerDown={e => + this._props.menu.setUpButtonClick(e, () => { + specialFunc?.(); + runInAction(() => { + this._layout.type = option; + this.updateRenderedDocCollection(); + }); + }) + }> + {option} + </div> + ); + }; + + get optionsMenuContents() { + 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"> + {this.layoutOption(LayoutType.FREEFORM, undefined, () => { + if (!this._layout.columns) this._layout.columns = Math.ceil(Math.sqrt(this.docsToRender.length)); + })} + {this.layoutOption(LayoutType.CAROUSEL)} + {this.layoutOption(LayoutType.CAROUSEL3D)} + {this.layoutOption(LayoutType.MASONRY)} + </div> + </div> + </div> + {this._layout.type ? this.layoutConfigOptions : null} + {this.layoutPreviewContents()} + {this.selectionBox( + 60, + 20, + 'repeat', + undefined, + repeatOptions.map(num => <option key={num} onPointerDown={() => (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(); + //previous implementation deprecated; return later to add or scrap + return; + }, 'save layout') + ) + }> + <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(); + this.renderedDocCollection && this._props.menu.addRenderedCollectionToMainview(this.renderedDocCollection); + }, 'make docs') + ) + }> + <FontAwesomeIcon icon="plus" /> + </button> + </div> + </div> + ); + } + + render() { + return this.optionsMenuContents; + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.ts new file mode 100644 index 000000000..e2a2a3c1c --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.ts @@ -0,0 +1,179 @@ +import { makeAutoObservable } from 'mobx'; +import { Col } from './DocCreatorMenu'; +import { TemplateFieldType, TemplateLayouts } from './TemplateBackend'; +import { DynamicField } from './TemplateFieldTypes/DynamicField'; +import { FieldSettings, TemplateField, ViewType } from './TemplateFieldTypes/TemplateField'; +import { Conditional } from './Backend/TemplateManager'; +import { TemplateDataField } from './TemplateFieldTypes/DataField'; + +export class Template { + _mainField: DynamicField; + + private _dataFields: TemplateDataField[] = []; + + /** + * A Template can be created from a description of its fields (FieldSettings) or from a DynamicField + * @param definition definition of template as settings or DynamicField + */ + constructor(definition: FieldSettings | DynamicField) { + makeAutoObservable(this); + this._mainField = definition instanceof DynamicField ? definition : this.setupMainField(definition); + } + + get childFields() { return this._mainField?.getSubfields ?? []; } // prettier-ignore + get allFields() { return this._mainField?.getAllSubfields ?? []; } // prettier-ignore + get contentFields() { return this.allFields.filter(field => field.isContentField); } // prettier-ignore + get doc() { return this._mainField?.renderedDoc; } // prettier-ignore + get title() { return this._mainField?.getTitle(); } // prettier-ignore + get descriptionSummary() { return this.contentFields.map(f => `--- Field #${f.getID} (title: ${f.getTitle()}): ${f.getDescription ?? ''} ---`).join(); } // prettier-ignore + get compiledContent() { return this.contentFields.map(f => `--- Field #${f.getID} (title: ${f.getTitle()}): ${f.getContent() ?? ''} ---`).join(); } // prettier-ignore + + cleanup = () => { + //dispose each subfields disposers, etc. + }; + + clone = (withContent: boolean = false) => { + const clone = new Template(this._mainField?.makeClone(undefined, withContent) ?? TemplateLayouts.BasicSettings); + this._dataFields.forEach(field => clone.addDataField(field.title)); + return clone; + }; + + getRenderedDoc = () => this.doc; + + getFieldByID = (id: number): TemplateField | undefined => this.allFields.filter(field => field.getID === id)[0]; + + getFieldByTitle = (title: string) => [...this.allFields, ...this._dataFields].filter(field => field.getTitle() === title)[0]; + + setupMainField = (templateInfo: FieldSettings) => TemplateField.CreateField(templateInfo, 1, undefined) as DynamicField; + + assignColToField = (fieldID: number, col: Col) => { + const field = this.getFieldByID(fieldID); + field?.setContent(col.defaultContent ?? '', col.type === TemplateFieldType.VISUAL ? ViewType.IMG : ViewType.TEXT); + field?.setTitle(col.title); + }; + + addDataField = (title: string, content?: string) => this._dataFields.push(new TemplateDataField(title, content)); + + removeDataField = (title: string) => (this._dataFields = this._dataFields.filter(field => field.title !== title)); + + isValidTemplate = (cols: Col[]) => this.title !== 'template_framework' && this.maxMatches(this.getMatches(cols)) === this.contentFields.length; + + applyConditionalLogicToField = (field: TemplateField | TemplateDataField, logic: Record<string, Conditional[]>) => { + if (field instanceof DynamicField) return; + const fieldStatements = logic[field.getTitle()]; + const content = field.getContent(); + fieldStatements?.forEach(statement => { + if (content === statement.condition) { + if (statement.target === 'Template') { + if (this._mainField.renderedDoc) { + this._mainField.renderedDoc[statement.attribute] = statement.value; + Object.assign(this._mainField.settings.opts, { [statement.attribute]: statement.value }); + } + } else { + const targetField = this.getFieldByTitle(statement.target); + if (targetField instanceof TemplateField && targetField.renderedDoc) { + targetField.renderedDoc[statement.attribute] = statement.value; + Object.assign(targetField.settings.opts, { [statement.attribute]: statement.value }); + } + } + } + }); + }; + + applyConditionalLogic = (logic: Record<string, Conditional[]>) => { + [...this.allFields, ...this._dataFields].forEach(field => this.applyConditionalLogicToField(field, logic)); + return this.getRenderedDoc(); + }; + + setImageAsBackground(url: string, makeTransparent: boolean = false) { + const fieldSettings: FieldSettings = { + tl: [-1, -1], + br: [1, 1], + opts: {}, + viewType: ViewType.IMG, + }; + + const field = TemplateField.CreateField(fieldSettings, Math.random() * 100 + 100, this._mainField); + field.setContent(url); + + if (makeTransparent) { + this.allFields.forEach(aField => { + aField.updateDocSetting('backgroundColor', 'transparent'); + aField.updateDocSetting('borderWidth', '0'); + }); + } + + this._mainField.makeBackgroundField(field); + } + + /** + * This function is just a hack for now to get around weird document icon stuff (specifically it misses the background) + */ + setMatteBackground(makeTransparent: boolean = false) { + if (this._mainField.hasBackground) { + return; + } + + const fieldSettings: FieldSettings = { + tl: [-1, -1], + br: [1, 1], + opts: { backgroundColor: String(this._mainField.renderedDoc!.backgroundColor) }, + viewType: ViewType.TEXT, + }; + + const field = TemplateField.CreateField(fieldSettings, Math.random() * 100 + 100, this._mainField); + + makeTransparent && + this.allFields.forEach(aField => { + aField.updateDocSetting('backgroundColor', 'transparent'); + aField.updateDocSetting('borderWidth', '0'); + }); + + this._mainField.makeBackgroundField(field); + } + + getMatches = (cols: Col[]): number[][] => { + const numFields = this.contentFields.length; + + if (cols.length !== numFields) return []; + + const matches = Array<number[]>(numFields); + + this.contentFields.forEach((field, i) => (matches[i] = field.matches(cols))); + + return matches; + }; + + maxMatches = (matches: number[][]) => { + if (matches.length === 0) return 0; + + const fieldsCt = this.contentFields.length; + const used = Array<boolean>(fieldsCt).fill(false); + const mt = Array<number>(fieldsCt).fill(-1); + + const augmentingPath = (v: number): boolean => { + if (!used[v]) { + 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; + }; +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.tsx deleted file mode 100644 index 0a5097d4a..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Doc, FieldType } from "../../../../../fields/Doc"; -import { Col } from "./DocCreatorMenu"; -import { DynamicField } from "./FieldTypes/DynamicField"; -import { Field, FieldSettings, ViewType } from "./FieldTypes/Field"; -import { } from "./FieldTypes/FieldUtils"; -import { } from "./FieldTypes/StaticField"; - -export class Template { - - mainField: DynamicField; - settings: FieldSettings; - - constructor(templateInfo: FieldSettings) { - this.mainField = this.setupMainField(templateInfo); - this.settings = templateInfo; - } - - get childFields(): Field[] { return this.mainField.getSubfields }; - get allFields(): Field[] { return this.mainField.getAllSubfields }; - get contentFields(): Field[] { return this.allFields.filter(field => field.getViewType === ViewType.STATIC) }; - get doc(){ return this.mainField.renderedDoc(); }; - - cloneBase = () => { - const clone: Template = new Template(this.settings); - clone.allFields.forEach(field => { - const matchingField: Field = this.allFields.filter(f => f.getID === field.getID)[0]; - matchingField.applyAttributes(field); - }) - return clone; - } - - getRenderedDoc = () => { - const doc: Doc = this.mainField.renderedDoc(); - this.contentFields.forEach(field => { - const title: string = field.getTitle(); - const val: FieldType = field.getContent() as FieldType; - if (!title || !val) return; - doc[title] = val; - }); - return doc; - } - - getFieldByID = (id: number): Field => { - return this.allFields.filter(field => field.getID === id)[0]; - } - - getFieldByTitle = (title: string) => { - return this.allFields.filter(field => field.getTitle() === title)[0]; - } - - setupMainField = (templateInfo: FieldSettings) => { - return new DynamicField(templateInfo, 1); - } - - get descriptionSummary(): string { - let summary: string = ''; - this.contentFields.forEach(field => { - summary += `--- Field #${field.getID} (title: ${field.getTitle()}): ${field.getDescription ?? ''} ---`; - }); - return summary; - } - - get compiledContent(): string { - let summary: string = ''; - this.contentFields.forEach(field => { - summary += `--- Field #${field.getID} (title: ${field.getTitle()}): ${field.getContent() ?? ''} ---`; - }); - return summary; - } - - renderUpdates = () => { - this.allFields.forEach(field => { - field.updateRenderedDoc(field.renderedDoc()); - }); - }; - - resetToBase = () => { - this.allFields.forEach(field => { - field.updateRenderedDoc(); - }) - } - - isValidTemplate = (cols: Col[]) => { - const matches: number[][] = this.getMatches(cols); - const maxMatches: number = this.maxMatches(matches); - return maxMatches === this.contentFields.length; - } - - getMatches = (cols: Col[]): number[][] => { - const numFields = this.contentFields.length; - - if (cols.length !== numFields) return []; - - const matches: number[][] = Array(numFields) - .fill([]) - .map(() => []); - - this.contentFields.forEach((field, i) => { - matches[i] = (field.matches(cols)); - }); - - return matches; - } - - maxMatches = (matches: number[][]) => { - if (matches.length === 0) return 0; - - const fieldsCt = this.contentFields.length; - 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; - }; - -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.ts index d3282eda3..26fd3a8fc 100644 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.tsx +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.ts @@ -1,10 +1,10 @@ -import { FieldSettings, ViewType } from "./FieldTypes/Field"; -import { } from "./FieldTypes/StaticField"; +import { FieldSettings, ViewType } from './TemplateFieldTypes/TemplateField'; export enum TemplateFieldType { TEXT = 'text', VISUAL = 'visual', UNSET = 'unset', + DATA = 'data', } export enum TemplateFieldSize { @@ -20,6 +20,14 @@ export class TemplateLayouts { return Object.values(TemplateLayouts); } + public static BasicSettings: FieldSettings = { + title: 'template_framework', + tl: [0, 0], + br: [400, 700], + viewType: ViewType.FREEFORM, + opts: {}, + }; + public static FourField001: FieldSettings = { title: 'fourfield001', tl: [0, 0], @@ -27,7 +35,7 @@ export class TemplateLayouts { viewType: ViewType.FREEFORM, opts: { backgroundColor: '#C0B887', - cornerRounding: .05, + _layout_borderRounding: '.05', //borderColor: '#6B461F', //borderWidth: '12', }, @@ -41,9 +49,9 @@ export class TemplateLayouts { description: 'A title field for very short text that contextualizes the content.', opts: { backgroundColor: 'transparent', - color: '#F1F0E9', - contentXCentering: 'h-center', - fontBold: true, + text_fontColor: '#F1F0E9', + hCentering: 'h-center', + contentBold: true, }, }, { @@ -54,9 +62,9 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'The main focus of the template; could be an image, long text, etc.', opts: { - cornerRounding: .05, + _layout_borderRounding: '.05', borderColor: '#8F5B25', - borderWidth: '6', + borderWidth: 6, backgroundColor: '#CECAB9', }, }, @@ -69,8 +77,8 @@ export class TemplateLayouts { description: 'A caption for field #2, very short text.', opts: { backgroundColor: 'transparent', - contentXCentering: 'h-center', - color: '#F1F0E9', + hCentering: 'h-center', + text_fontColor: '#F1F0E9', }, }, { @@ -81,9 +89,9 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium-sized field for medium/long text.', opts: { - cornerRounding: .05, + _layout_borderRounding: '.05', borderColor: '#8F5B25', - borderWidth: '6', + borderWidth: 6, backgroundColor: '#CECAB9', }, }, @@ -93,7 +101,7 @@ export class TemplateLayouts { public static FourField002: FieldSettings = { title: 'fourfield002', viewType: ViewType.FREEFORM, - tl: [0,0], + tl: [0, 0], br: [425, 778], opts: { backgroundColor: '#242425', @@ -107,10 +115,10 @@ export class TemplateLayouts { 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', + borderWidth: 8, borderColor: '#F8E71C', backgroundColor: '#242425', - color: 'white', + text_fontColor: 'white', }, }, { @@ -122,9 +130,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: 'transparent', - color: 'white', - contentXCentering: 'h-center', - fontTransform: 'uppercase', + text_fontColor: 'white', + hCentering: 'h-center', + text_transform: 'uppercase', }, }, { @@ -136,9 +144,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: 'transparent', - color: 'white', - contentXCentering: 'h-center', - fontTransform: 'uppercase', + text_fontColor: 'white', + hCentering: 'h-center', + text_transform: 'uppercase', }, }, { @@ -149,9 +157,9 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large-sized field suitable for longer text that should contextualize field 1.', opts: { - borderWidth: '8', + borderWidth: 8, borderColor: '#F8E71C', - color: 'white', + text_fontColor: 'white', backgroundColor: '#242425', }, }, @@ -161,7 +169,7 @@ export class TemplateLayouts { br: [-0.525, 0.075], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -170,7 +178,7 @@ export class TemplateLayouts { br: [-0.2175, 0.0245], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -179,7 +187,7 @@ export class TemplateLayouts { br: [0.045, 0.0245], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -188,7 +196,7 @@ export class TemplateLayouts { br: [0.3075, 0.0245], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -197,7 +205,7 @@ export class TemplateLayouts { br: [0.8, 0.075], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, ], @@ -266,8 +274,8 @@ export class TemplateLayouts { public static FourField004: FieldSettings = { title: 'fourfield04', viewType: ViewType.FREEFORM, - tl: [0,0], - br: [414,583], + tl: [0, 0], + br: [414, 583], opts: { backgroundColor: '#6CCAF0', //borderColor: '#1088C3', @@ -283,9 +291,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: '#E2B4F5', - borderWidth: '9', + borderWidth: 9, borderColor: '#9222F1', - contentXCentering: 'h-center', + hCentering: 'h-center', }, }, { @@ -297,9 +305,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: '#F5B4DD', - borderWidth: '9', + borderWidth: 9, borderColor: '#E260F3', - contentXCentering: 'h-center', + hCentering: 'h-center', }, }, { @@ -310,7 +318,7 @@ export class TemplateLayouts { 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', + borderWidth: 16, borderColor: '#A2BD77', }, }, @@ -322,7 +330,7 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], description: 'A medium to large field for text that describes the visual content above', opts: { - borderWidth: '9', + borderWidth: 9, borderColor: '#F0D601', backgroundColor: '#F3F57D', }, @@ -334,7 +342,7 @@ export class TemplateLayouts { opts: { backgroundColor: 'transparent', borderColor: '#007C0C', - borderWidth: '10', + borderWidth: 10, }, }, ], @@ -343,218 +351,229 @@ export class TemplateLayouts { public static FourField005: FieldSettings = { title: 'fourfield05', viewType: ViewType.FREEFORM, - tl: [0,0], - br: [400,550], + tl: [0, 0], + br: [400, 514], opts: { backgroundColor: '#95A575', }, subfields: [ { viewType: ViewType.STATIC, - tl: [-0.9, -.925], - br: [-.075, -.775], + tl: [-0.9, -0.925], + br: [-0.075, -0.775], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], description: 'A small text field for a title or word(s) that categorize the rest of the content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', - contentXCentering: "h-center", + borderWidth: 8, + hCentering: 'h-center', backgroundColor: '#B8DC90', }, }, { viewType: ViewType.STATIC, - tl: [.075, -.925], - br: [.9, -.775], + tl: [0.075, -0.925], + br: [0.9, -0.775], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], description: 'A small text field for a title that categorizes the rest of the content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', - contentXCentering: "h-center", + borderWidth: 8, + hCentering: 'h-center', backgroundColor: '#B8DC90', }, }, { viewType: ViewType.DEC, - tl: [-.82, -.4], - br: [-.5, -.2], + tl: [-0.82, -0.4], + br: [-0.5, -0.2], opts: { backgroundColor: '#94B058', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.STATIC, - tl: [-0.66, -.65], - br: [0.66, .25], + tl: [-0.66, -0.65], + br: [0.66, 0.25], types: [TemplateFieldType.VISUAL], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], description: 'A medium to large field in the center of the template, for the main visual content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, backgroundColor: '#B8DC90', }, }, { viewType: ViewType.STATIC, - tl: [-.875, .425], - br: [0.875, .925], + tl: [-0.875, 0.425], + br: [0.875, 0.925], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], description: 'A medium to large field at the bottom of the template, for the main text content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', - contentXCentering: "h-center", + borderWidth: 8, + hCentering: 'h-center', backgroundColor: '#B8DC90', }, }, { viewType: ViewType.DEC, - tl: [-1.1, -.62], - br: [-.9, -.5], + tl: [-1.1, -0.62], + br: [-0.9, -0.5], opts: { backgroundColor: '#7A9D31', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, tl: [-1.1, 0], - br: [-.9, .15], + br: [-0.9, 0.15], opts: { backgroundColor: '#94B058', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [-.93, -.265], - br: [-.715, -.125], + tl: [-0.93, -0.265], + br: [-0.715, -0.125], opts: { backgroundColor: '#728745', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [.7, -.45], - br: [.85, -.3], + tl: [0.7, -0.45], + br: [0.85, -0.3], opts: { backgroundColor: '#7A9D31', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [.8, .03], - br: [1.2, .33], + tl: [0.8, 0.03], + br: [1.2, 0.33], opts: { backgroundColor: '#728745', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [.875, -.13], - br: [1.2, .12], + tl: [0.875, -0.13], + br: [1.2, 0.12], opts: { backgroundColor: '#94B058', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, - ] - } + ], + }; public static FourFieldCarousel: FieldSettings = { title: 'title_fourfieldcarousel', viewType: ViewType.FREEFORM, - tl:[0,0], - br:[500, 600], + tl: [0, 0], + br: [500, 600], opts: { - backgroundColor: '#DDD3A9', + backgroundColor: '#D7CBAB', }, subfields: [ { viewType: ViewType.STATIC, - tl: [-0.8, -.9], - br: [0.8, -.5], + tl: [-0.8, -0.9], + br: [0.8, -0.5], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], description: 'A small text field for a title that categorizes the rest of the content.', opts: { - borderColor: 'yellow', - borderWidth: '8', - contentXCentering: "h-center", + hCentering: 'h-center', backgroundColor: 'transparent', + text_transform: 'uppercase', }, }, { viewType: ViewType.CAROUSEL3D, - tl: [-0.9, -.3], - br: [0.9, .9], + tl: [-0.9, -0.5], + br: [0.9, 0.25], opts: { - borderColor: 'yellow', - borderWidth: '8', - backgroundColor: 'transparent', + borderColor: '#847F69', + borderWidth: 8, + backgroundColor: '#C8BA94', }, subfields: [ { viewType: ViewType.STATIC, - tl: [-.3, -.6], - br: [.3, .6], + tl: [-0.4, -0.6], + br: [0.4, 0.6], types: [TemplateFieldType.VISUAL, TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large field for content that will share central focus with other content in the carousel.', opts: { - borderColor: 'yellow', - borderWidth: '8', + //borderColor: 'yellow', + //borderWidth: '8', }, }, { viewType: ViewType.STATIC, - tl: [-.3, -.6], - br: [.3, .6], + tl: [-0.4, -0.6], + br: [0.4, 0.6], types: [TemplateFieldType.VISUAL, TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large field for content that will share central focus with other content in the carousel.', opts: { - borderColor: 'black', - borderWidth: '8', + //borderColor: 'black', + //borderWidth: '8', }, }, { viewType: ViewType.STATIC, - tl: [-.3, -.6], - br: [.3, .6], + tl: [-0.4, -0.6], + br: [0.4, 0.6], types: [TemplateFieldType.VISUAL, TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large field for content that will share central focus with other content in the carousel.', opts: { - borderColor: 'yellow', - borderWidth: '8', + //borderColor: 'yellow', + //borderWidth: '8', }, }, - ] + ], }, - ] - } + { + viewType: ViewType.STATIC, + tl: [-0.9, 0.35], + br: [0.9, 0.9], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], + description: 'A medium text field for a description of the content in the carousel.', + opts: { + hCentering: 'h-center', + backgroundColor: 'transparent', + }, + }, + ], + }; public static ThreeField001: FieldSettings = { title: 'threefield001', viewType: ViewType.FREEFORM, - tl: [0,0], + tl: [0, 0], br: [575, 770], opts: { backgroundColor: '#DDD3A9', @@ -567,23 +586,23 @@ export class TemplateLayouts { description: 'A medium to large field for visual content that is the central focus.', opts: { borderColor: 'yellow', - borderWidth: '8', + borderWidth: 8, backgroundColor: '#DDD3A9', - rotation: 45, + _rotation: 45, }, subfields: [ { - viewType: ViewType.STATIC, - tl: [-1.25, -1.25], - br: [1.25, 1.25], - 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: { - rotation: -45, + viewType: ViewType.STATIC, + tl: [-1.25, -1.25], + br: [1.25, 1.25], + 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: { + _rotation: -45, + }, }, - }, - ] + ], }, { viewType: ViewType.STATIC, @@ -594,7 +613,7 @@ export class TemplateLayouts { description: 'A very small text field for one to a few words. A good caption for the image.', opts: { backgroundColor: 'transparent', - contentXCentering: 'h-center', + hCentering: 'h-center', }, }, { @@ -606,7 +625,7 @@ export class TemplateLayouts { description: 'A medium to large text field for a thorough description of the image. ', opts: { backgroundColor: 'transparent', - color: 'white', + text_fontColor: 'white', }, }, { @@ -615,18 +634,18 @@ export class TemplateLayouts { br: [1.8, -0.66], opts: { backgroundColor: '#CEB155', - rotation: 45, + _rotation: 45, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, { viewType: ViewType.FREEFORM, @@ -634,18 +653,18 @@ export class TemplateLayouts { br: [-0.2, -0.66], opts: { backgroundColor: '#CEB155', - rotation: 135, + _rotation: 135, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, { viewType: ViewType.FREEFORM, @@ -653,18 +672,18 @@ export class TemplateLayouts { br: [1.66, 1.25], opts: { backgroundColor: '#CEB155', - rotation: 135, + _rotation: 135, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, { viewType: ViewType.FREEFORM, @@ -672,18 +691,18 @@ export class TemplateLayouts { br: [-0.33, 1.25], opts: { backgroundColor: '#CEB155', - rotation: 45, + _rotation: 45, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, ], }; @@ -691,7 +710,7 @@ export class TemplateLayouts { public static ThreeField002: FieldSettings = { title: 'threefield002', viewType: ViewType.FREEFORM, - tl: [0,0], + tl: [0, 0], br: [477, 662], opts: { backgroundColor: '#9E9C95', @@ -705,7 +724,7 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large visual field for the main content of the template', opts: { - borderWidth: '15', + borderWidth: 15, borderColor: '#E0E0DA', }, }, @@ -718,10 +737,10 @@ export class TemplateLayouts { 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', + text_fontColor: '#AF0D0D', + text_transform: 'uppercase', + contentBold: true, + hCentering: 'h-left', }, }, { @@ -733,8 +752,8 @@ export class TemplateLayouts { 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', + text_fontColor: 'black', + hCentering: 'h-right', }, }, { @@ -747,6 +766,4 @@ export class TemplateLayouts { }, ], }; -} - - +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DataField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DataField.ts new file mode 100644 index 000000000..6b4086483 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DataField.ts @@ -0,0 +1,20 @@ + +import { ViewType } from "./TemplateField"; + +export class TemplateDataField { + + viewType: ViewType = ViewType.NONE; + + title: string = ''; + content: string | undefined; + + constructor(title: string, content?: string) { + this.title = title; + this.content = content; + } + + setContent(content: string, viewType?: ViewType) { this.content = content } + getContent() { return this.content } + + getTitle() { return this.title } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DecorationField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DecorationField.ts new file mode 100644 index 000000000..98a9dc7a6 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DecorationField.ts @@ -0,0 +1,3 @@ +import { DynamicField } from './DynamicField'; + +export class DecorationField extends DynamicField {} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DynamicField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DynamicField.ts new file mode 100644 index 000000000..b9042258b --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DynamicField.ts @@ -0,0 +1,131 @@ +import { IDisposer } from 'mobx-utils'; +import { Doc } from '../../../../../../fields/Doc'; +import { DocData } from '../../../../../../fields/DocSymbols'; +import { List } from '../../../../../../fields/List'; +import { NumCast } from '../../../../../../fields/Types'; +import { Docs } from '../../../../../documents/Documents'; +import { DocumentType } from '../../../../../documents/DocumentTypes'; +import { FieldSettings, TemplateField, ViewType } from './TemplateField'; + +export class DynamicField extends TemplateField { + protected _disposers: { [name: string]: IDisposer } = {}; + protected _subfields: TemplateField[] = []; + protected backgroundField: TemplateField | undefined; + + get getSubfields() { + return this._subfields; + } + get getAllSubfields(): TemplateField[] { + return this.getSubfields.flatMap(field => [field, ...((field as DynamicField).getAllSubfields ?? [])]); + } + + get hasBackground() { + return this.backgroundField !== undefined; + } + + handleFieldUpdate = (newDocsList: Doc[]) => { + const currRenderedDocs = new Set(this.getSubfields.filter(field => field.renderedDoc).map(field => field.renderedDoc!)); + newDocsList.forEach(doc => !currRenderedDocs.has(doc) && this.addFieldFromDoc(doc)); + currRenderedDocs.forEach(doc => { + if (!newDocsList.includes(doc)) { + this._subfields.forEach(field => field.renderedDoc === doc && this.removeField(field)); + } + }); + }; + + addFieldFromDoc = (doc: Doc) => { + const par = this._renderDoc; + const settings: FieldSettings = { + tl: [Number(doc._x) / NumCast(par?._width, 1), Number(doc?._y) / NumCast(par?._height, 1)], + br: [(Number(doc._x) + Number(doc._width)) / NumCast(par?._width, 1), (Number(doc._y) + Number(doc._height)) / NumCast(par?._height, 1)], + viewType: doc.type === DocumentType.COL ? ViewType.FREEFORM : ViewType.STATIC, + opts: {}, + }; + + this._subfields.push(TemplateField.CreateField(settings, this._subfields.length, this)); + }; + + addField = (field: TemplateField, layer: number = 0) => { + if (!this._subfields.includes(field)) { + this._subfields.splice(layer, 0, field); + } + }; + + dispose = () => Object.values(this._disposers).forEach(disposer => disposer?.()); + + removeField = (field: TemplateField) => { + // field.renderedDoc && this._renderDoc && Doc.RemoveDocFromList(this._renderDoc, undefined, field.renderedDoc); + this._subfields.splice(this._subfields.indexOf(field), 1); + (field as DynamicField).dispose?.(); + }; + + // implement Field's abstract method for replacing a subfield with a new one + exchangeFields(newField: TemplateField, oldField: TemplateField) { + this._subfields.splice(this._subfields.indexOf(oldField), 1, newField); + this.refreshRenderedDoc(); + } + + get isContentField(): boolean { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setContent(content: string, type: ViewType) {} + + getContent = () => ''; + + addChildToDocument = (doc: Doc) => this._renderDoc && Doc.SetContainer(doc, this._renderDoc); + + makeBackgroundField = (field: TemplateField) => { + if (this.backgroundField && this.backgroundField !== field) { + this.removeField(this.backgroundField); + this.backgroundField = undefined; + } + if (field && field !== this.backgroundField) { + this.addField(field); + this.backgroundField = field; + } + this.refreshRenderedDoc(); + } + + matches = (): Array<number> => []; + + makeClone(parent?: DynamicField, withContent: boolean = false) { + const dynClone = super.makeClone(parent) as DynamicField; + dynClone._subfields = this.getSubfields.map(field => { + if (field === this.backgroundField) { + const backgroundField: TemplateField = field.makeClone(dynClone, true); + dynClone.makeBackgroundField(backgroundField); + return backgroundField; + } else { + return field.makeClone(dynClone, withContent) + } + }); + if (dynClone._renderDoc) { + dynClone._renderDoc[DocData].data = new List<Doc>(dynClone.getSubfields.filter(sub => sub.renderedDoc).map(sub => sub.renderedDoc!)); + } + return dynClone; + } + + initRenderDoc = (settings: FieldSettings) => { + //this._disposers.fieldList = reaction(() => DocListCast(this._renderDoc?.[Doc.LayoutFieldKey(this._renderDoc)]), this.handleFieldUpdate); + this._subfields = settings.subfields?.map((fieldSettings, index) => {return TemplateField.CreateField(fieldSettings, index, this)}) || []; + const renderedSubfields = this._subfields.filter(field => field.renderedDoc).map(field => field.renderedDoc!); + settings.opts.title = settings.title; + this._renderDoc = (() => { switch (settings.viewType) { + case ViewType.CAROUSEL3D: return Docs.Create.Carousel3DDocument(renderedSubfields, settings.opts); + case ViewType.FREEFORM: + default: return Docs.Create.FreeformDocument(renderedSubfields, settings.opts); + }})(); // prettier-ignore + return this; + }; + + refreshRenderedDoc = () => { + const renderedSubfields = this._subfields.filter(field => field.renderedDoc).map(field => field.renderedDoc!); + this._renderDoc = (() => { switch (this.settings.viewType) { + case ViewType.CAROUSEL3D: return Docs.Create.Carousel3DDocument(renderedSubfields, this.settings.opts); + case ViewType.FREEFORM: + default: return Docs.Create.FreeformDocument(renderedSubfields, this.settings.opts); + }})(); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/StaticContentField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/StaticContentField.ts new file mode 100644 index 000000000..569c43af4 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/StaticContentField.ts @@ -0,0 +1,62 @@ +import { FieldResult } from '../../../../../../fields/Doc'; +import { DocData } from '../../../../../../fields/DocSymbols'; +import { RichTextField } from '../../../../../../fields/RichTextField'; +import { ImageField } from '../../../../../../fields/URLField'; +import { Docs } from '../../../../../documents/Documents'; +import { FieldSettings, TemplateField, ViewType } from './TemplateField'; +import { TemplateFieldUtils } from './TemplateFieldUtils'; + +export abstract class StaticContentField extends TemplateField { + protected _content: string = ''; + + getContent = () => this._content ?? 'unset'; + get isContentField(): boolean { + return true; + } + protected setDataContent(viewType: ViewType, fieldKey: string, data: FieldResult, content: string, type?: ViewType) { + super.setContent(content, type); + + if (type === viewType || type === undefined) { + this._content = content; + this._renderDoc && (this._renderDoc[DocData][fieldKey] = data); + } else { + this.changeFieldType(type).setContent(content, type); + } + } +} + +export class ImageTemplateField extends StaticContentField { + setContent(url: string, type?: ViewType) { + this.setDataContent(ViewType.IMG, 'data', new ImageField(url), url, type); + this._renderDoc!['backgroundColor'] = 'white'; + } + + initRenderDoc(settings: FieldSettings) { + settings.opts.title = settings.title ?? ''; + settings.opts._layout_fitWidth = false; + this._renderDoc = Docs.Create.ImageDocument(this._content, settings.opts); + return this; + } + + updateDocSetting(setting: string, newVal: string) { + if (this._renderDoc) this._renderDoc[setting] = newVal; + if (setting !== 'backgroundColor') { + const settings: {[s: string]: string } = {[setting]: newVal} + Object.assign(this.settings.opts, settings); + } + } +} + +export class TextTemplateField extends StaticContentField { + setContent(text: string, type?: ViewType) { + const fontSize: number = TemplateFieldUtils.calculateFontSize(this._dimensions?.width ?? 10, this._dimensions?.height ?? 10, text, true); + this.setDataContent(ViewType.TEXT, 'text', RichTextField.textToRtf(text, undefined, {fontSize: fontSize}), text, type); + } + + initRenderDoc(settings: FieldSettings) { + settings.opts.title = settings.title ?? ''; + settings.opts.text_fontSize = TemplateFieldUtils.calculateFontSize(this._dimensions?.width ?? 10, this._dimensions?.height ?? 10, '', true) + ''; + this._renderDoc = Docs.Create.TextDocument(this._content, settings.opts); + return this; + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateField.ts new file mode 100644 index 000000000..091ef834a --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateField.ts @@ -0,0 +1,172 @@ +/* eslint-disable no-use-before-define */ +import { Doc } from '../../../../../../fields/Doc'; +import { DocumentOptions } from '../../../../../documents/Documents'; +import { Col } from '../DocCreatorMenu'; +import { Template } from '../Template'; +import { TemplateFieldSize, TemplateFieldType } from '../TemplateBackend'; + +export abstract class TemplateField { + /** + * Creates and initializes a new TemplateField based on the settings and parameters + * + * implemented in FieldUtils and assigned in main (to avoid import cycles) + * + * @param settings - specification of the field type and other parameters + * @param index - + * @param parent - TemplateField that contains the new field + * @param sameId - + * @returns TemplateField + */ + static CreateField: (settings: FieldSettings, index: number, parent: TemplateField | undefined, sameId?: boolean) => TemplateField; + + protected _parent?: TemplateField; + protected _id: number; + protected _title: string = ''; + protected _settings: FieldSettings; + protected _renderDoc: Doc | undefined; + protected _dimensions: FieldDimensions | undefined; + + constructor(settings: FieldSettings, id: number = 1, parent?: TemplateField) { + this._id = id; + this._parent = parent; + this._settings = settings; + this._title = settings.title ?? ''; + this._dimensions = this.getLocalDimensions(this._settings, this._parent?.getDimensions); + this.applyBasicOpts(this._dimensions, settings); + return this; + } + + get renderedDoc() { + return this._renderDoc; + } + get getDimensions() { + return this._dimensions; + } + get getID() { + return this._id; + } + get getDescription(): string { + return this._settings?.description ?? ''; + } + get viewType(): ViewType | undefined { + return this._settings?.viewType; + } + + get settings(): FieldSettings { + return this._settings; + } + + abstract get isContentField(): boolean; + abstract initRenderDoc(settings: FieldSettings): TemplateField; + abstract getContent(): string; + + setContent(content: string, type?: ViewType) { + if (type) this._settings.viewType = type; + } + + setTitle = (title: string) => { + this._title = title; + this.settings.title = title; + if (this._renderDoc) this._renderDoc.title = title; + }; + getTitle = () => this._title; + + updateDocSetting(setting: string, newVal: string) { + if (this._renderDoc) this._renderDoc[setting] = newVal; + const settings: { [s: string]: string } = { [setting]: newVal }; + Object.assign(this.settings.opts, settings); + } + + makeClone(parent?: TemplateField, withContent: boolean = false) { + const settings: FieldSettings = structuredClone(this._settings); + const cloned = TemplateField.CreateField(settings, this._id, parent, true); // create a value for this.Document/subfields that we want to ignore + cloned.renderedDoc!.width = this.renderedDoc!.width; + cloned.renderedDoc!.height = this.renderedDoc!.height; + cloned.renderedDoc!.x = this.renderedDoc!.x; + cloned.renderedDoc!.y = this.renderedDoc!.y; + cloned.renderedDoc!.backgroundColor = this.renderedDoc!.backgroundColor; + cloned.setTitle(this._title); + cloned._dimensions = this._dimensions; + withContent && cloned.setContent(this.getContent()); + return cloned; + } + + exchangeFields(newField: TemplateField, oldField: TemplateField) { + throw new Error('Only DynamicField can exchange fields.' + newField._title + ' ' + oldField._title); + } + + changeFieldType = (newType: ViewType): TemplateField => { + this._settings.viewType = newType; + const newField = TemplateField.CreateField(this._settings, this._id, this._parent, true); + this._parent?.exchangeFields(newField, this); + return newField; + }; + + matches = (cols: Col[]): number[] => { + const colMatchesField = (col: Col) => (this._settings?.sizes?.some(size => col.sizes?.includes(size)) && this._settings.types?.includes(col.type)) ?? false; + + const matches: Array<number> = []; + + cols.forEach((col, v) => { + if (colMatchesField(col)) { + matches.push(v); + } + }); + + return matches; + }; + + private getLocalDimensions = (coords: { tl: [number, number]; br: [number, number] }, parentDimensions?: FieldDimensions): FieldDimensions => { + if (!parentDimensions) { + return { width: coords.br[0] - coords.tl[0], height: coords.br[1] - coords.tl[1], coord: { x: coords.tl[0], y: coords.tl[1] } }; + } + const l = (coords.tl[0] * parentDimensions.width) / 2; + const t = coords.tl[1] * parentDimensions.height / 2; //prettier-ignore + const r = (coords.br[0] * parentDimensions.width) / 2; + const b = coords.br[1] * parentDimensions.height / 2; //prettier-ignore + return { width: r-l, height: b-t, coord: { x: l, y: t } }; //prettier-ignore + }; + + private applyBasicOpts = (dimensions: FieldDimensions, settings: FieldSettings | undefined) => { + const opts: DocumentOptions = settings?.opts ?? {}; + opts.isDefaultTemplateDoc ??= true; + opts._layout_hideScroll ??= true; + opts.x ??= dimensions.coord.x; + opts.y ??= dimensions.coord.y; + opts._height ??= dimensions.height; + opts._width ??= dimensions.width; + opts._nativeWidth ??= dimensions.width; + opts._nativeHeight ??= dimensions.height; + opts._layout_nativeDimEditable ??= true; + opts.layout_boxShadow = 'none'; + }; +} + +export type FieldSettings = { + tl: [number, number]; + br: [number, number]; + opts: DocumentOptions; + types?: TemplateFieldType[]; + sizes?: TemplateFieldSize[]; + title?: string; + viewType: ViewType; + template?: Template; + subfields?: FieldSettings[]; + description?: string; +}; + +export enum ViewType { + CAROUSEL3D = 'carousel3d', + FREEFORM = 'freeform', + STATIC = 'static', + DEC = 'decoration', + IMG = 'image', + TEXT = 'text', + NONE = 'none', +} + +export type FieldDimensions = { + width: number; + height: number; + coord: { x: number; y: number }; +}; diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateFieldUtils.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateFieldUtils.ts new file mode 100644 index 000000000..b0b531b57 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateFieldUtils.ts @@ -0,0 +1,71 @@ +import { DecorationField } from './DecorationField'; +import { DynamicField } from './DynamicField'; +import { ImageTemplateField, TextTemplateField } from './StaticContentField'; +import { FieldSettings, TemplateField, ViewType } from './TemplateField'; + +export class TemplateFieldUtils { + /** + * Creates and initializes a new TemplateField based on the settings and parameters + * + * implements Field.initField ... see main.tsx + * + * @param settings - specification of the field type and other parameters + * @param index - + * @param parent - optional TemplateField that contains the new field + * @param sameId - + * @returns TemplateField + */ + public static CreateField = (settings: FieldSettings, index: number, parent?: TemplateField, sameId: boolean = false): TemplateField => + ((...args) => { + switch (settings?.viewType) { + case ViewType.FREEFORM: + case ViewType.CAROUSEL3D: return new DynamicField(...args).initRenderDoc(settings); + case ViewType.IMG: return new ImageTemplateField(...args).initRenderDoc(settings); + case ViewType.TEXT: return new TextTemplateField(...args).initRenderDoc(settings); + case ViewType.DEC: return new DecorationField(...args).initRenderDoc(settings); + default: return new TextTemplateField(...args).initRenderDoc(settings); + } // prettier-ignore + })(settings, sameId ? index : parent ? Number(`${parent.getID}${index}`) : 1, parent); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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.7; + + 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; + + currFontSize += 1; + } + + return currFontSize - 1; + }; +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateManager.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateManager.tsx deleted file mode 100644 index 50ae4d72a..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateManager.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Col } from "./DocCreatorMenu"; -import { FieldSettings } from "./FieldTypes/Field"; -import { Template } from "./Template"; - -export class TemplateManager { - - templates: Template[] = []; - - constructor(templateSettings: FieldSettings[]) { - this.templates = this.initializeTemplates(templateSettings); - } - - initializeTemplates = (templateSettings: FieldSettings[]): Template[] => { - const initializedTemplates: Template[] = []; - templateSettings.forEach(settings => initializedTemplates.push(new Template(settings))); - return initializedTemplates; - } - - getValidTemplates = (cols: Col[]): Template[] => { - return this.templates.filter(template => template.isValidTemplate(cols)); - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss b/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss index 63a693918..0acc2c847 100644 --- a/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss +++ b/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss @@ -13,7 +13,7 @@ $highlightedText: #82e0ff; min-height: 200px; border-radius: 15px; padding: 15px; - padding-bottom: 0; + padding-bottom: 0px; z-index: 999; display: flex; flex-direction: column; @@ -40,7 +40,7 @@ $highlightedText: #82e0ff; font-size: 12px; font-weight: 400; letter-spacing: 1px; - margin: 0; + margin: 0px; padding-right: 5px; } @@ -124,8 +124,8 @@ $highlightedText: #82e0ff; .img-container::after { content: ''; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss index ff1fa343d..a22e1153c 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -91,7 +91,7 @@ margin: 5px; margin-left: 25px; margin-right: 10px; - margin-bottom: 0; + margin-bottom: 0px; .tableBox-table { height: 100%; width: 100%; @@ -101,7 +101,7 @@ text-overflow: ellipsis; width: 100%; white-space: pre; - max-width: 150; + max-width: 150px; overflow: hidden; margin-left: 2px; } diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx index a7c4a00b0..f51683991 100644 --- a/src/client/views/nodes/DataVizBox/components/Histogram.tsx +++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx @@ -413,6 +413,10 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { }); }; + setChartRef = (r: HTMLDivElement | null) => { + this._histogramRef = r; + r && this.drawChart(this._histogramData, this.width, this.height); + }; render() { if (!this.selectedBins) this.layoutDoc.dataViz_histogram_selectedBins = new List<string>(); @@ -446,12 +450,7 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { size={Size.XSMALL} /> </div> - <div - ref={r => { - this._histogramRef = r; - r && this.drawChart(this._histogramData, this.width, this.height); - }} - /> + <div ref={this.setChartRef} /> {selected !== 'none' ? ( <div className="selected-data"> Selected: {selected} diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx index 80fadf178..732681e05 100644 --- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx @@ -347,6 +347,10 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { .style('pointer-events', 'none') .style('transform', `translate(${xScale(d0.x) - this.width}px,${yScale(d0.y)}px)`); } + setLineRef = (r: HTMLDivElement | null) => { + this._lineChartRef = r; + this.drawChart([this._lineChartData], this.rangeVals, this.width, this.height); + }; render() { const selectedPt = this._currSelected ? `{ ${this._props.axes[0]}: ${this._currSelected.x} ${this._props.axes[1]}: ${this._currSelected.y} }` : 'none'; @@ -378,12 +382,7 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { fillWidth /> </div> - <div - ref={r => { - this._lineChartRef = r; - this.drawChart([this._lineChartData], this.rangeVals, this.width, this.height); - }} - /> + <div ref={this.setLineRef} /> {selectedPt !== 'none' ? ( <div className="selected-data"> {`Selected: ${selectedPt}`} diff --git a/src/client/views/nodes/DataVizBox/components/PieChart.tsx b/src/client/views/nodes/DataVizBox/components/PieChart.tsx index 0ae70786f..cf476b8d0 100644 --- a/src/client/views/nodes/DataVizBox/components/PieChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx @@ -373,10 +373,10 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { const sliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/</g, '') : sliceTitle; const sliceColors = Cast(this._props.layoutDoc.dataViz_pie_sliceColors, listSpec('string'), null); - sliceColors.forEach(each => { + sliceColors?.forEach(each => { if (each.split('::')[0] === sliceName) sliceColors.splice(sliceColors.indexOf(each), 1); }); - sliceColors.push(StrCast(sliceName + '::' + color)); + sliceColors?.push(StrCast(sliceName + '::' + color)); }; @action changeHistogramCheckBox = () => { @@ -384,6 +384,10 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { this.drawChart(this._pieChartData, this.width, this.height); }; + setChartRef = (r: HTMLDivElement | null) => { + this._piechartRef = r; + this.drawChart(this._pieChartData, this.width, this.height); + }; render() { let titleAccessor = 'dataViz_pie_title'; if (this._props.axes.length === 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; @@ -443,12 +447,7 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { Organize data as histogram </div> ) : null} - <div - ref={r => { - this._piechartRef = r; - this.drawChart(this._pieChartData, this.width, this.height); - }} - /> + <div ref={this.setChartRef} /> {selected !== 'none' ? ( <div className="selected-data"> Selected: {selected} diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index ad2731109..cc08cf269 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -100,7 +100,7 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { return this._props.docView?.()?.screenToViewTransform().Scale || 1; } @computed get rowHeight() { - return (this.viewScale * this._tableHeight) / this._tableDataIds.length; + return (this.viewScale * this._tableHeight) / (this._tableDataIds.length + 1); // add 1 for header row } @computed get startID() { return this.rowHeight ? Math.max(Math.floor(this._scrollTop / this.rowHeight) - 1, 0) : 0; @@ -400,12 +400,12 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { this._tableHeight = r?.getBoundingClientRect().height ?? 0; } })}> - <div style={{ height: this.startID * Number(DATA_VIZ_TABLE_ROW_HEIGHT) }} /> <thead> + <tr style={{ height: this.startID * Number(DATA_VIZ_TABLE_ROW_HEIGHT.replace("px","")) }} /> <tr> - {this.columns.map(col => ( + {this.columns.map((col, i) => ( <th - key={this.columns.indexOf(col)} + key={i} style={{ color: this._props.axes.slice().reverse().lastElement() === col @@ -440,7 +440,7 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { <tbody> {this._tableDataIds .filter((rowId, i) => this.startID - 2 <= i && i <= this.endID + 2) - ?.map(rowId => ( + .map(rowId => ( <tr key={rowId} className={`tableBox-row ${this.columns[0]}`} @@ -456,22 +456,22 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { : '', border: rowId === this._props.specHighlightedRow ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', }}> - {this.columns.map(col => { + {this.columns.map((col, i) => { let colSelected = false; if (this._props.axes.length > 2) colSelected = this._props.axes[0] === col || this._props.axes[1] === col || this._props.axes[2] === col; else if (this._props.axes.length > 1) colSelected = this._props.axes[0] === col || this._props.axes[1] === col; else if (this._props.axes.length > 0) colSelected = this._props.axes[0] === col; if (this._props.titleCol === col) colSelected = true; return ( - <td key={this.columns.indexOf(col)} style={{ border: colSelected ? '3px solid black' : '1px solid black', fontWeight: colSelected ? 'bolder' : 'normal' }}> + <td key={i} style={{ border: (colSelected ? '3' : '1') + 'px solid black', fontWeight: colSelected ? 'bolder' : 'normal' }}> <div className="tableBox-cell">{this._props.records[rowId][col] as string | number}</div> </td> ); })} </tr> ))} + <tr style={{ display: this._tableDataIds.length - this.endID ? undefined : 'none', height: (this._tableDataIds.length - this.endID) * Number(DATA_VIZ_TABLE_ROW_HEIGHT.replace("px","")) }} /> </tbody> - <div style={{ height: (this._tableDataIds.length - this.endID) * Number(DATA_VIZ_TABLE_ROW_HEIGHT) }} /> </table> </div> </div> diff --git a/src/client/views/nodes/DiagramBox.tsx b/src/client/views/nodes/DiagramBox.tsx index 7cfccf0dc..6a31f64ce 100644 --- a/src/client/views/nodes/DiagramBox.tsx +++ b/src/client/views/nodes/DiagramBox.tsx @@ -185,6 +185,8 @@ export class DiagramBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return '( )'; }; + setRef = (r: HTMLDivElement | null) => this.fixWheelEvents(r, this._props.isContentActive); + setDiagramBoxRef = (r: HTMLDivElement | null) => r && this.renderMermaidAsync.call(this, this.removeWords(this.mermaidcode), r); render() { return ( <div @@ -192,7 +194,7 @@ export class DiagramBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { style={{ pointerEvents: this._props.isContentActive() ? undefined : 'none', }} - ref={r => this.fixWheelEvents(r, this._props.isContentActive)}> + ref={this.setRef}> <div className="DIYNodeBox-searchbar"> <input type="text" value={this._inputValue} onKeyDown={action(e => e.key === 'Enter' && this.generateMermaidCode())} onChange={action(e => (this._inputValue = e.target.value))} /> <button type="button" onClick={this.generateMermaidCode}> @@ -208,7 +210,7 @@ export class DiagramBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { ) : this._generating ? ( <div className="loading-circle" /> ) : ( - <div className="diagramBox" ref={r => r && this.renderMermaidAsync.call(this, this.removeWords(this.mermaidcode), r)}> + <div className="diagramBox" ref={this.setDiagramBoxRef}> {this._errorMessage || 'Type a prompt to generate a diagram'} </div> )} diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 504c1491e..32741a0fe 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -261,9 +261,7 @@ export class DocumentContentsView extends ObservableReactComponent<DocumentConte jsx={layoutFrame} showWarnings // eslint-disable-next-line @typescript-eslint/no-explicit-any - onError={(test: any) => { - console.log('DocumentContentsView:' + test, bindings, layoutFrame); - }} + onError={(test: any) => console.log('DocumentContentsView:' + test, bindings, layoutFrame)} /> ); } diff --git a/src/client/views/nodes/DocumentLinksButton.scss b/src/client/views/nodes/DocumentLinksButton.scss index e1b83dc59..43b1e083f 100644 --- a/src/client/views/nodes/DocumentLinksButton.scss +++ b/src/client/views/nodes/DocumentLinksButton.scss @@ -17,8 +17,8 @@ } .documentLinksButton-cont { - min-width: 20; - min-height: 20; + min-width: 20px; + min-height: 20px; position: absolute; } diff --git a/src/client/views/nodes/DocumentLinksButton.tsx b/src/client/views/nodes/DocumentLinksButton.tsx index c35a329c9..d30f00829 100644 --- a/src/client/views/nodes/DocumentLinksButton.tsx +++ b/src/client/views/nodes/DocumentLinksButton.tsx @@ -38,7 +38,6 @@ export class DocButtonState { // eslint-disable-next-line no-use-before-define public static _instance: DocButtonState | undefined; public static get Instance() { - // eslint-disable-next-line no-return-assign return DocButtonState._instance ?? (DocButtonState._instance = new DocButtonState()); } constructor() { diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index c4351a200..98ca76339 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -1,6 +1,7 @@ @use '../global/globalCssVariables.module.scss' as global; .documentView-effectsWrapper { + height: 100%; border-radius: inherit; transition: inherit; } @@ -14,13 +15,13 @@ width: 100%; height: 100%; position: absolute; - top: 0; + top: 0px; } .documentView-node { position: inherit; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; border-radius: inherit; @@ -55,7 +56,7 @@ .documentView-htmlOverlay { position: absolute; display: flex; - top: 0; + top: 0px; height: 100%; width: 100%; .documentView-htmlOverlayInner { @@ -79,9 +80,9 @@ .documentView-audioBackground { display: inline-block; width: 25px; - height: 25; + height: 25px; position: absolute; - top: 0; + top: 0px; left: 50%; border-radius: 25px; background: white; @@ -130,7 +131,7 @@ width: 30px; border-radius: 50%; position: absolute; - right: -15; + right: -15px; opacity: 0.9; pointer-events: auto; background-color: #9dca96; @@ -147,8 +148,8 @@ .documentView-anchorCont { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; display: inline-block; @@ -160,8 +161,8 @@ position: absolute; width: 100%; height: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; } .documentView-styleWrapper { @@ -183,9 +184,9 @@ .documentView-titleWrapper-hover { color: global.$black; transform-origin: top left; - top: 0; + top: 0px; width: 100%; - height: 14; + height: 14px; opacity: 0.5; text-align: center; text-overflow: ellipsis; @@ -211,7 +212,7 @@ .documentView-captionWrapper { position: absolute; - bottom: 0; + bottom: 0px; width: 100%; overflow-y: auto; transform-origin: bottom left; @@ -275,20 +276,20 @@ .documentView-noAIWidgets { transform-origin: top left; position: absolute; - bottom: 0; + bottom: 0px; pointer-events: none; } .documentView-widgetDecorations { transform-origin: top right; position: absolute; - top: 0; - right: 0; + top: 0px; + right: 0px; } .documentView-editorView-history { position: absolute; transform-origin: top right; - right: 0; + right: 0px; top: 0; overflow-y: scroll; scrollbar-width: thin; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 05706fe6b..bd71115db 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -701,16 +701,18 @@ export class DocumentViewInternal extends DocComponent<DocumentViewProps & Field aiContentsWidth = () => (this.aiContentsHeight() * (this._props.NativeWidth?.() || 1)) / (this._props.NativeHeight?.() || 1); aiContentsHeight = () => Math.max(10, this._props.PanelHeight() - (this._aiWinHeight + (this.tagsOverlayFunc() ? 22 : 0)) * this.uiBtnScaling); + setAiRef = action((r: HTMLDivElement | null) => this.historyRef(this._oldHistoryWheel, (this._oldHistoryWheel = r))); + @computed get aiEditor() { return ( <div className="documentView-editorView" + ref={this.setAiRef} style={{ background: SnappingManager.userVariantColor, width: `${100 / this.uiBtnScaling}%`, // transform: `scale(${this.uiBtnScaling})`, - }} - ref={r => this.historyRef(this._oldHistoryWheel, (this._oldHistoryWheel = r))}> + }}> <div className="documentView-editorView-resizer" /> {this._componentView?.componentAIView?.() ?? null} {this._props.DocumentView?.() ? <TagsView background={this.backgroundBoxColor} Views={[this._props.DocumentView?.()]} /> : null} @@ -720,7 +722,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewProps & Field @computed get tagsOverlay() { return ( <div - className="documentView-noAiWidgets" + className="documentView-noAIWidgets" style={{ width: `${100 / this.uiBtnScaling}%`, // transform: `scale(${this.uiBtnScaling})`, @@ -744,7 +746,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewProps & Field @computed get viewBoxContents() { TraceMobx(); const isInk = this.layoutDoc._layout_isSvg && !this._props.LayoutTemplateString; - const noBackground = this.Document.isGroup && !this._componentView?.isUnstyledView?.() && (!this.layoutDoc.backgroundColor || this.layoutDoc.backgroundColor === 'transparent'); + const noBackground = Doc.IsFreeformGroup(this.Document) && !this._componentView?.isUnstyledView?.() && (!this.layoutDoc.backgroundColor || this.layoutDoc.backgroundColor === 'transparent'); return ( <> <div @@ -1412,7 +1414,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { } public static setDefaultImageTemplate(checkResult?: boolean) { if (checkResult) return Doc.UserDoc().defaultImageLayout; - const view = DocumentView.Selected()[0]?._props.renderDepth > 0 ? DocumentView.Selected()[0] : undefined; + const view = DocumentView.Selected()[0]?._props.renderDepth > 0 || DocumentView.Selected()[0]?.Document.isTemplateDoc ? DocumentView.Selected()[0] : undefined; undoable(() => { const tempDoc = DocumentView.getTemplate(view); Doc.UserDoc().defaultImageLayout = tempDoc ? new PrefetchProxy(tempDoc) : undefined; @@ -1507,18 +1509,16 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { .translate(-(this._docViewInternal?.aiShift() ?? 0), 0) .scale((this._docViewInternal?.aiScale() ?? 1) / this.nativeScaling); + setHtmlOverlayRef = (r: HTMLDivElement | null) => { + const val = r?.style.display !== 'none'; // if the outer overlay has been displayed, trigger the innner div to start it's opacity fade in transition + if (r && val !== this._enableHtmlOverlayTransitions) { + setTimeout(action(() => (this._enableHtmlOverlayTransitions = val))); + } + }; htmlOverlay = () => { const effect = StrCast(this._htmlOverlayEffect?.presentation_effect, StrCast(this._htmlOverlayEffect?.followLinkAnimEffect)); return ( - <div - className="documentView-htmlOverlay" - ref={r => { - const val = r?.style.display !== 'none'; // if the outer overlay has been displayed, trigger the innner div to start it's opacity fade in transition - if (r && val !== this._enableHtmlOverlayTransitions) { - setTimeout(action(() => (this._enableHtmlOverlayTransitions = val))); - } - }} - style={{ display: !this._htmlOverlayText ? 'none' : undefined }}> + <div className="documentView-htmlOverlay" ref={this.setHtmlOverlayRef} style={{ display: !this._htmlOverlayText ? 'none' : undefined }}> <div className="documentView-htmlOverlayInner" style={{ transition: `all 500ms`, opacity: this._enableHtmlOverlayTransitions ? 0.9 : 0 }}> {DocumentViewInternal.AnimationEffect( <div className="webBox-textHighlight"> diff --git a/src/client/views/nodes/EquationBox.tsx b/src/client/views/nodes/EquationBox.tsx index 3cacb6692..2ce24b688 100644 --- a/src/client/views/nodes/EquationBox.tsx +++ b/src/client/views/nodes/EquationBox.tsx @@ -6,7 +6,7 @@ import { TraceMobx } from '../../../fields/util'; import { DocUtils } from '../../documents/DocUtils'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; -import { undoBatch } from '../../util/UndoManager'; +import { undoBatch, UndoManager } from '../../util/UndoManager'; import { ViewBoxBaseComponent } from '../DocComponent'; import { StyleProp } from '../StyleProp'; import { DocumentView } from './DocumentView'; @@ -14,6 +14,7 @@ import './EquationBox.scss'; import { FieldView, FieldViewProps } from './FieldView'; import EquationEditor from './formattedText/EquationEditor'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; +import { Doc } from '../../../fields/Doc'; @observer export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { @@ -21,6 +22,7 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { return FieldView.LayoutString(EquationBox, fieldKey); } _ref: React.RefObject<EquationEditor> = React.createRef(); + _liveTextUndo: UndoManager.Batch | undefined; // captured undo batch when typing a new text note into a collection constructor(props: FieldViewProps) { super(props); @@ -29,12 +31,17 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { componentDidMount() { this._props.setContentViewBox?.(this); - if (DocumentView.SelectOnLoad === this.Document && (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView?.()))) { + if (DocumentView.SelectOnLoad === this.rootDoc && (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView?.()))) { + this._liveTextUndo = FormattedTextBox.LiveTextUndo; + FormattedTextBox.LiveTextUndo = undefined; this._props.select(false); + this.dataDoc[Doc.LayoutDataKey(this.Document)] = FormattedTextBox.SelectOnLoadChar ?? ''; + this._ref.current?.mathField.focus(); - this.dataDoc.text === 'x' && this._ref.current?.mathField.select(); + this.dataDoc[Doc.LayoutDataKey(this.Document)] === 'x' && this._ref.current?.mathField.select(); DocumentView.SetSelectOnLoad(undefined); + FormattedTextBox.SelectOnLoadChar = ''; } reaction( () => this._props.isSelected(), @@ -53,7 +60,7 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { @action keyPressed = (e: KeyboardEvent) => { if (e.key === 'Enter') { - const nextEq = Docs.Create.EquationDocument(e.shiftKey ? StrCast(this.dataDoc.text) : '', { + const nextEq = Docs.Create.EquationDocument(e.shiftKey ? StrCast(this.dataDoc[Doc.LayoutDataKey(this.Document)]) : '', { title: '# math', _width: NumCast(this.layoutDoc._width), _height: NumCast(this.layoutDoc._height), @@ -70,9 +77,10 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { e.stopPropagation(); } if (e.key === 'Tab') { + const target = this.Document.isTemplateDoc ? this.rootDoc : this.Document; const graph = Docs.Create.FunctionPlotDocument([this.Document], { - x: NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width), - y: NumCast(this.layoutDoc.y), + x: NumCast(target.x) + NumCast(this.layoutDoc._width), + y: NumCast(target.y), _width: 400, _height: 300, backgroundColor: 'white', @@ -82,11 +90,11 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { link && this._props.addDocument?.(link); e.stopPropagation(); } - if (e.key === 'Backspace' && !this.dataDoc.text) this._props.removeDocument?.(this.Document); + if (e.key === 'Backspace' && !this.dataDoc[Doc.LayoutDataKey(this.Document)]) this._props.removeDocument?.(this.Document); }; @undoBatch onChange = (str: string) => { - this.dataDoc.text = str; + this.dataDoc[Doc.LayoutDataKey(this.Document)] = str; }; updateSize = (mathSpan: HTMLSpanElement) => { @@ -103,19 +111,22 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { this.layoutDoc._width = mathWidth * nScale; this.layoutDoc._height = mathHeight * nScale; + if (this.layoutDoc._nativeWidth) { + this.layoutDoc._nativeWidth = mathWidth; + this.layoutDoc._nativeHeight = mathHeight; + } }; + setRef = (r: HTMLDivElement) => r && this._ref.current?.element.current && this.updateSize(this._ref.current?.element.current); render() { TraceMobx(); const scale = this._props.NativeDimScaling?.() || 1; return ( <div - ref={r => r && this._ref.current?.element.current && this.updateSize(this._ref.current?.element.current)} + ref={this.setRef} className="equationBox-cont" onKeyDown={e => e.stopPropagation()} onPointerDown={e => !e.ctrlKey && e.stopPropagation()} - onBlur={() => { - FormattedTextBox.LiveTextUndo?.end(); - }} + onBlur={() => this._liveTextUndo?.end()} style={{ transform: `scale(${scale})`, minWidth: `${100 / scale}%`, @@ -128,7 +139,14 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { paddingTop: NumCast(this.layoutDoc.yMargin), paddingBottom: NumCast(this.layoutDoc.yMargin), }}> - <EquationEditor ref={this._ref} value={StrCast(this.dataDoc.text, '')} spaceBehavesLikeTab onChange={this.onChange} autoCommands="pi theta sqrt sum prod alpha beta gamma rho" autoOperatorNames="sin cos tan" /> + <EquationEditor + ref={this._ref} + value={StrCast(this.dataDoc[Doc.LayoutDataKey(this.Document)], '')} + spaceBehavesLikeTab + onChange={this.onChange} + autoCommands="pi theta sqrt sum prod alpha beta gamma rho" + autoOperatorNames="sin cos tan" + /> </div> ); } diff --git a/src/client/views/nodes/FontIconBox/FontIconBadge.scss b/src/client/views/nodes/FontIconBox/FontIconBadge.scss index 2ff5c651f..e741936db 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBadge.scss +++ b/src/client/views/nodes/FontIconBox/FontIconBadge.scss @@ -6,7 +6,7 @@ color: black; display: block; position: absolute; - right: 5; + right: 5px; border-radius: 50%; text-align: center; } diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.scss b/src/client/views/nodes/FontIconBox/FontIconBox.scss index 8bc68c131..52eebba54 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBox.scss +++ b/src/client/views/nodes/FontIconBox/FontIconBox.scss @@ -42,7 +42,7 @@ letter-spacing: normal; background-color: inherit; border-radius: 8px; - padding: 0; + padding: 0px; width: 100%; font-family: 'system-ui'; text-transform: uppercase; @@ -96,22 +96,22 @@ display: inline-block; width: 100%; height: 25px; - margin: 0; + margin: 0px; } .switch input { opacity: 0; - width: 0; - height: 0; + width: 0px; + height: 0px; } .slider { position: absolute; cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; background-color: lightgrey; -webkit-transition: 0.4s; transition: 0.4s; @@ -223,7 +223,7 @@ height: fit-content; color: black; top: 100%; - left: 0; + left: 0px; z-index: 21; background-color: #e3e3e3; box-shadow: 0px 3px 4px rgba(0, 0, 0, 0.3); @@ -430,7 +430,7 @@ border-radius: 0px 7px 7px 0px; width: 13px; height: 100%; - right: 0; + right: 0px; } .menuButton-dropdown-header { diff --git a/src/client/views/nodes/FunctionPlotBox.tsx b/src/client/views/nodes/FunctionPlotBox.tsx index 8e4b64851..e4d37e006 100644 --- a/src/client/views/nodes/FunctionPlotBox.tsx +++ b/src/client/views/nodes/FunctionPlotBox.tsx @@ -121,11 +121,12 @@ export class FunctionPlotBox extends ViewBoxAnnotatableComponent<FieldViewProps> } // if (this.layout_autoHeight) this.tryUpdateScrollHeight(); }; + setRef = (r: HTMLDivElement | null) => r && this.createGraph(r); @computed get theGraph() { return ( <div id={`${this._plotId}`} - ref={r => r && this.createGraph(r)} + ref={this.setRef} style={{ position: 'absolute', width: '100%', height: '100%' }} onPointerDown={e => { e.stopPropagation(); diff --git a/src/client/views/nodes/IconTagBox.scss b/src/client/views/nodes/IconTagBox.scss index d6cf95958..c0977dfc5 100644 --- a/src/client/views/nodes/IconTagBox.scss +++ b/src/client/views/nodes/IconTagBox.scss @@ -15,7 +15,7 @@ width: 20px; height: 20px; margin: auto; - padding: 0; + padding: 0px; border-radius: 50%; background-color: global.$dark-gray; background-color: transparent; diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 5a6292fab..90ede69dc 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -3,14 +3,14 @@ width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; transform-origin: top left; .imageBox-annotationLayer { position: absolute; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; // bcz: makes text fuzzy! @@ -24,8 +24,8 @@ #upload-icon { position: absolute; - bottom: 0; - right: 0; + bottom: 0px; + right: 0px; width: 20px; height: 20px; } @@ -51,8 +51,8 @@ .imageBox-dot { position: absolute; - bottom: 10; - left: 0; + bottom: 10px; + left: 0px; border-radius: 10px; width: 20px; height: 20px; @@ -131,8 +131,8 @@ position: absolute; color: white; background: black; - right: 0; - bottom: 0; + right: 0px; + bottom: 0px; z-index: 2; transform-origin: bottom right; cursor: default; @@ -142,13 +142,13 @@ } } .imageBox-regenerateDropTarget { - right: 35; + right: 35px; transform-origin: 70px 35px; } .imageBox-fader img { position: absolute; - left: 0; + left: 0px; } .imageBox-fadeBlocker-hover { @@ -223,7 +223,7 @@ max-width: 90%; width: 100%; .imageBox-aiView-similarity { - max-width: 65; + max-width: 65px; overflow: hidden; text-overflow: ellipsis; width: 100%; @@ -250,7 +250,7 @@ z-index: 10000; h3 { - margin-top: 0; + margin-top: 0px; } input { diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index f7ad5c7e2..78bacdcac 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -8,7 +8,7 @@ import { extname } from 'path'; import * as React from 'react'; import { AiOutlineSend } from 'react-icons/ai'; import ReactLoading from 'react-loading'; -import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; +import { ClientUtils, imageUrlToBase64, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon, returnTrue } from '../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; @@ -16,7 +16,7 @@ import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { ComputedField } from '../../../fields/ScriptField'; -import { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast } from '../../../fields/Types'; +import { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast, ImageCastWithSuffix } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; @@ -45,6 +45,7 @@ import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; +import { gptImageLabel } from '../../apis/gpt/GPT'; const DefaultPath = '/assets/unknown-file-icon-hi.png'; export class ImageEditorData { @@ -71,7 +72,7 @@ export class ImageEditorData { public static set AddDoc(addDoc: Opt<(doc: Doc | Doc[], annotationKey?: string) => boolean>) { ImageEditorData.set(this.imageData.open, this.imageData.rootDoc, this.imageData.source, addDoc); } // prettier-ignore } -const API_URL = 'https://api.unsplash.com/search/photos'; +const UNSPLASH_API = 'https://api.unsplash.com/search/photos'; @observer export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { public static LayoutString(fieldKey: string) { @@ -112,11 +113,69 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this._props.setContentViewBox?.(this); } + @computed get outpaintOriginalSize(): { width: number; height: number } { + return { + width: NumCast(this.Document[this.fieldKey + '_outpaintOriginalWidth']), + height: NumCast(this.Document[this.fieldKey + '_outpaintOriginalHeight']), + }; + } + set outpaintOriginalSize(prop: { width: number; height: number } | undefined) { + this.Document[this.fieldKey + '_outpaintOriginalWidth'] = prop?.width; + this.Document[this.fieldKey + '_outpaintOriginalHeight'] = prop?.height; + } + + @computed get imgNativeSize() { + return { + nativeWidth: NumCast(this.dataDoc[this.fieldKey + '_nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth'], 500)), + nativeHeight: NumCast(this.dataDoc[this.fieldKey + '_nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '_nativeHeight'], 500)), + }; + } + set imgNativeSize(prop: { nativeWidth: number; nativeHeight: number }) { + this.dataDoc[this.fieldKey + '_nativeWidth'] = prop.nativeWidth; + this.dataDoc[this.fieldKey + '_nativeHeight'] = prop.nativeHeight; + } + protected createDropTarget = (ele: HTMLDivElement) => { this._mainCont = ele; this._dropDisposer?.(); ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document)); }; + + autoTag = async () => { + if (this.Document.$tags_chat) return; + try { + // 1) grab the full-size URL + const layoutKey = Doc.LayoutDataKey(this.Document); + const url = ImageCastWithSuffix(this.Document[layoutKey], '_o'); + if (!url) throw new Error('No image URL found'); + + // 2) convert to base64 + const base64 = await imageUrlToBase64(url); + if (!base64) throw new Error('Failed to load image data'); + + // 3) ask GPT for labels one label: PERSON or LANDSCAPE + const label = await gptImageLabel( + base64, + `Classify this image as PERSON or LANDSCAPE. You may only respond with one of these two options. + Then provide five additional descriptive tags to describe the image for a total of 6 words outputted, delimited by spaces. + For example: "LANDSCAPE BUNNY NATURE FOREST PEACEFUL OUTDOORS". + Then add one final lengthier summary tag (separated by underscores) that describes the image.` + ).then(raw => raw.trim().toUpperCase()); + + const { nativeWidth, nativeHeight } = this.nativeSize; + const aspectRatio = ((nativeWidth || 1) / (nativeHeight || 1)).toFixed(2); + + // 5) stash it on the Doc + // overwrite any old tags so re-runs still work + this.Document.$tags_chat = new List<string>([...label.split(/\s+/), `ASPECT_${aspectRatio}`]); + + // 6) flip on “show tags” in the layout + this.Document._layout_showTags = true; + } catch (err) { + console.error('autoTag failed:', err); + } + }; + getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor const anchor = @@ -153,7 +212,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { () => ({ nativeSize: this.nativeSize, width: NumCast(this.layoutDoc._width), height: this.layoutDoc._height }), ({ nativeSize, width, height }) => { if (!this.layoutDoc._layout_nativeDimEditable || !height || this.layoutDoc.layout_resetNativeDim) { - this.layoutDoc.layout_resetNativeDim = undefined; // template images need to reset their dimensions when they are rendered with content. afterwards, remove this flag. + if (!this._props.TemplateDataDocument) this.layoutDoc._nativeWidth = this.layoutDoc._nativeHeight = undefined; + this.layoutDoc.layout_resetNativeDim = undefined; // reset dimensions of templates rendered with content or if image changes. afterwards, remove this flag. this.layoutDoc._height = (width * nativeSize.nativeHeight) / nativeSize.nativeWidth; } }, @@ -170,7 +230,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { { fireImmediately: true } ); this._disposers.outpaint = reaction( - () => this.Document[this.fieldKey + '_outpaintOriginalWidth'] !== undefined && !SnappingManager.ShiftKey, + () => this.outpaintOriginalSize?.width && !SnappingManager.ShiftKey, complete => complete && this.openOutpaintPrompt(), { fireImmediately: true } ); @@ -185,7 +245,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { */ fetchImages = async () => { try { - const { data } = await axios.get(`${API_URL}?query=${this._searchInput}&page=1&per_page=${1}&client_id=${process.env.VITE_API_KEY}`); + const { data } = await axios.get(`${UNSPLASH_API}?query=${this._searchInput}&page=1&per_page=${1}&client_id=${process.env.VITE_API_KEY}`); const imageSnapshot = Docs.Create.ImageDocument(data.results[0].urls.small, { _nativeWidth: Doc.NativeWidth(this.layoutDoc), _nativeHeight: Doc.NativeHeight(this.layoutDoc), @@ -202,10 +262,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }; - handleSelection = async (selection: string) => { - this._searchInput = selection; - }; - drop = undoable( action((e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData && !this._props.rejectDrop?.(de, this.DocumentView?.())) { @@ -231,14 +287,15 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { DrawingFillHandler.drawingToImage(this.Document, 90, newPrompt(descText), drag)?.then(action(() => (this._regenerateLoading = false))); added = false; } else if (de.altKey || !this.dataDoc[this.fieldKey]) { - const layoutDoc = de.complete.docDragData?.draggedDocuments[0]; - const targetField = Doc.LayoutDataKey(layoutDoc); - const targetDoc = layoutDoc[DocData]; - if (targetDoc[targetField] instanceof ImageField) { + const dropDoc = de.complete.docDragData?.draggedDocuments[0]; + const dropDocFieldKey = Doc.LayoutDataKey(dropDoc); + const dropDataDoc = dropDoc[DocData]; + if (dropDataDoc[dropDocFieldKey] instanceof ImageField) { added = true; - this.dataDoc[this.fieldKey] = ObjectField.MakeCopy(targetDoc[targetField] as ImageField); - Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(targetDoc), this.fieldKey); - Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(targetDoc), this.fieldKey); + this.dataDoc.layout_resetNativeDim = true; + this.dataDoc[this.fieldKey] = ObjectField.MakeCopy(dropDataDoc[dropDocFieldKey] as ImageField); + Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(dropDataDoc), this.fieldKey); + Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(dropDataDoc), this.fieldKey); } } added === false && e.preventDefault(); @@ -257,18 +314,17 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @undoBatch setNativeSize = action(() => { - const oldnativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']); + const oldnativeWidth = this.imgNativeSize.nativeWidth; const nscale = NumCast(this._props.PanelWidth()) * NumCast(this.layoutDoc._freeform_scale, 1); const nw = nscale / oldnativeWidth; - this.dataDoc[this.fieldKey + '_nativeHeight'] = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight']) * nw; - this.dataDoc[this.fieldKey + '_nativeWidth'] = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']) * nw; + this.imgNativeSize = { nativeWidth: this.imgNativeSize.nativeWidth * nw, nativeHeight: this.imgNativeSize.nativeHeight * nw }; this.dataDoc.freeform_panX = nw * NumCast(this.dataDoc.freeform_panX); this.dataDoc.freeform_panY = nw * NumCast(this.dataDoc.freeform_panY); this.dataDoc.freeform_panX_max = this.dataDoc.freeform_panX_max ? nw * NumCast(this.dataDoc.freeform_panX_max) : undefined; this.dataDoc.freeform_panX_min = this.dataDoc.freeform_panX_min ? nw * NumCast(this.dataDoc.freeform_panX_min) : undefined; this.dataDoc.freeform_panY_max = this.dataDoc.freeform_panY_max ? nw * NumCast(this.dataDoc.freeform_panY_max) : undefined; this.dataDoc.freeform_panY_min = this.dataDoc.freeform_panY_min ? nw * NumCast(this.dataDoc.freeform_panY_min) : undefined; - const newnativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']); + const newnativeWidth = this.imgNativeSize.nativeWidth; DocListCast(this.dataDoc[this.annotationKey]).forEach(doc => { doc.x = (NumCast(doc.x) / oldnativeWidth) * newnativeWidth; doc.y = (NumCast(doc.y) / oldnativeWidth) * newnativeWidth; @@ -280,13 +336,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }); @undoBatch rotate = action(() => { - const nw = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']); - const nh = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight']); + const nativeSize = this.imgNativeSize; const w = this.layoutDoc._width; const h = this.layoutDoc._height; this.dataDoc[this.fieldKey + '_rotation'] = (NumCast(this.dataDoc[this.fieldKey + '_rotation']) + 90) % 360; - this.dataDoc[this.fieldKey + '_nativeWidth'] = nh; - this.dataDoc[this.fieldKey + '_nativeHeight'] = nw; + this.imgNativeSize = { nativeWidth: nativeSize.nativeHeight, nativeHeight: nativeSize.nativeWidth }; // swap width and height this.layoutDoc._width = h; this.layoutDoc._height = w; }); @@ -302,7 +356,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const anchy = NumCast(cropping.y); const anchw = NumCast(cropping._width); const anchh = NumCast(cropping._height); - const viewScale = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']) / anchw; + const viewScale = this.nativeSize.nativeWidth / anchw; cropping.x = NumCast(this.Document.x) + NumCast(this.layoutDoc._width); cropping.y = NumCast(this.Document.y); cropping.onClick = undefined; @@ -364,18 +418,14 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @action cancelOutpaintPrompt = () => { - const origWidth = NumCast(this.Document[this.fieldKey + '_outpaintOriginalWidth']); - const origHeight = NumCast(this.Document[this.fieldKey + '_outpaintOriginalHeight']); - this.Document._width = origWidth; - this.Document._height = origHeight; + [this.Document._width, this.Document._height] = [this.outpaintOriginalSize.width, this.outpaintOriginalSize.height]; this._outpaintingInProgress = false; + this.outpaintOriginalSize = undefined; this.closeOutpaintPrompt(); }; @action - handlePromptChange = (val: string | number) => { - this._outpaintPromptInput = '' + val; - }; + handlePromptChange = (val: string | number) => (this._outpaintPromptInput = '' + val); @action submitOutpaintPrompt = () => { @@ -416,8 +466,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { loadingOverlay.innerHTML = '<div style="color: white; font-size: 16px;">Generating outpainted image...</div>'; this._mainCont?.appendChild(loadingOverlay); - const origWidth = NumCast(this.Document[this.fieldKey + '_outpaintOriginalWidth']); - const origHeight = NumCast(this.Document[this.fieldKey + '_outpaintOriginalHeight']); + const { width: origWidth, height: origHeight } = this.outpaintOriginalSize; const response = await Networking.PostToServer('/outpaintImage', { imageUrl: currentPath, prompt: customPrompt, @@ -454,8 +503,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this.Document.$ai = true; this.Document.$ai_outpainted = true; this.Document.$ai_outpaint_prompt = customPrompt; - this.Document[this.fieldKey + '_outpaintOriginalWidth'] = undefined; - this.Document[this.fieldKey + '_outpaintOriginalHeight'] = undefined; + this.outpaintOriginalSize = undefined; } else { this.cancelOutpaintPrompt(); alert('Failed to receive a valid image URL from server.'); @@ -478,6 +526,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return this._props.PanelWidth() / this._props.PanelHeight() < this.nativeSize.nativeWidth / this.nativeSize.nativeHeight; } + isOutpaintable = () => true; + componentUI = (/* boundsLeft: number, boundsTop: number*/) => !this._showOutpaintPrompt ? null : ( <div @@ -668,8 +718,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @computed get nativeSize() { TraceMobx(); if (this.paths.length && this.paths[0].includes(DefaultPath)) 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 { nativeWidth, nativeHeight } = this.imgNativeSize; const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '_nativeOrientation'], 1); return { nativeWidth, nativeHeight, nativeOrientation }; } @@ -689,7 +738,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @computed get overlayImageIcon() { const usePath = this.layoutDoc[`_${this.fieldKey}_usePath`]; - return ( + return this._regenerateLoading ? null : ( <Tooltip title={ <div className="dash-tooltip"> @@ -731,7 +780,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { ); } @computed get regenerateImageIcon() { - return ( + return this._regenerateLoading ? null : ( <Tooltip title={'click to show AI generations. Drop an image on to create a new generation'}> <div className="imageBox-regenerateDropTarget" @@ -820,7 +869,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { transform, transformOrigin, width: this._outpaintAlign ? 'max-content' : this._outpaintAlign ? '100%' : undefined, - height: this._outpaintVAlign ? 'max-content' : this.Document[this.fieldKey + '_outpaintOriginalWidth'] !== undefined ? '100%' : undefined, + height: this._outpaintVAlign ? 'max-content' : this.outpaintOriginalSize?.width ? '100%' : undefined, }} onError={action(e => (this._error = e.toString()))} draggable={false} @@ -943,10 +992,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return { width, height }; }; savedAnnotations = () => this._savedAnnotations; + showBorderRounding = returnTrue; + rejectDrop = (de: DragManager.DropEvent, subView?: DocumentView | undefined) => (this.dataDoc[this.fieldKey] === undefined ? true : (this._props.rejectDrop?.(de, subView) ?? false)); render() { TraceMobx(); - const borderRad = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BorderRounding) as string; - const borderRadius = borderRad?.includes('px') ? `${Number(borderRad.split('px')[0]) / (this._props.NativeDimScaling?.() || 1)}px` : borderRad; + const borderRadius = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BorderRounding) as string; return ( <> <div @@ -986,7 +1036,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { ScreenToLocalTransform={this.screenToLocalTransform} select={emptyFunction} focus={this.focus} - rejectDrop={this._props.rejectDrop} + rejectDrop={this.rejectDrop} getScrollHeight={this.getScrollHeight} NativeDimScaling={returnOne} isAnyChildContentActive={returnFalse} @@ -1048,8 +1098,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (result instanceof Error) { alert('Error uploading files - possibly due to unsupported file types'); } else { - this.dataDoc[this.fieldKey] = new ImageField(result.accessPaths.agnostic.client); - !(result instanceof Error) && DocUtils.assignUploadInfo(result, this.dataDoc); + runInAction(() => { + this.dataDoc.layout_resetNativeDim = true; + !(result instanceof Error) && DocUtils.assignUploadInfo(result, this.dataDoc, this.fieldKey); + this.dataDoc[this.fieldKey] = new ImageField(result.accessPaths.agnostic.client); + }); } disposer(); } else { diff --git a/src/client/views/nodes/KeyValueBox.scss b/src/client/views/nodes/KeyValueBox.scss index 441fceba4..80ace6ae0 100644 --- a/src/client/views/nodes/KeyValueBox.scss +++ b/src/client/views/nodes/KeyValueBox.scss @@ -93,9 +93,9 @@ $header-height: 30px; height: 30px; width: 5px; z-index: 20; - right: 0; - top: 0; - border-radius: 0; + right: 0px; + top: 0px; + border-radius: 0px; background: black; pointer-events: all; } @@ -105,8 +105,8 @@ $header-height: 30px; float: left; height: 37px; z-index: 20; - right: 0; - top: 0; + right: 0px; + top: 0px; background: transparent; pointer-events: none; } diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index aa66b5ba9..606f63d6d 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -273,15 +273,15 @@ export class KeyValueBox extends ViewBoxBaseComponent<FieldViewProps>() { }; createFieldView = (templateDoc: Doc, row: KeyValuePair) => { - const metaKey = row._props.keyName; - const fieldTempDoc = Doc.IsDelegateField(templateDoc, metaKey) ? Doc.MakeDelegate(templateDoc) : Doc.MakeEmbedding(templateDoc); - fieldTempDoc.title = metaKey; + const keyName = row._props.keyName; + const fieldTempDoc = Doc.IsDelegateField(templateDoc, keyName) ? Doc.MakeDelegate(templateDoc) : Doc.MakeEmbedding(templateDoc); + fieldTempDoc.title = keyName; fieldTempDoc.layout_fitWidth = true; fieldTempDoc._xMargin = 10; fieldTempDoc._yMargin = 10; fieldTempDoc._width = 100; fieldTempDoc._height = 40; - fieldTempDoc.layout = this.inferType(templateDoc[metaKey], metaKey); + fieldTempDoc.layout = this.inferType(templateDoc[keyName], keyName); return fieldTempDoc; }; diff --git a/src/client/views/nodes/KeyValuePair.scss b/src/client/views/nodes/KeyValuePair.scss index 913ab641c..154fbdcfa 100644 --- a/src/client/views/nodes/KeyValuePair.scss +++ b/src/client/views/nodes/KeyValuePair.scss @@ -17,7 +17,7 @@ } .keyValuePair-td-key-check { position: relative; - margin: 0; + margin: 0px; } .keyValuePair-keyField { width: 100%; diff --git a/src/client/views/nodes/LabelBox.scss b/src/client/views/nodes/LabelBox.scss index 889cdc0ca..e1974d6a0 100644 --- a/src/client/views/nodes/LabelBox.scss +++ b/src/client/views/nodes/LabelBox.scss @@ -24,8 +24,8 @@ .answer-icon { position: absolute; - right: 8; - bottom: 5; + right: 8px; + bottom: 5px; color: black; display: inline-block; font-size: 10px; @@ -36,8 +36,8 @@ .q-icon { position: absolute; - right: 6; - bottom: 5; + right: 6px; + bottom: 5px; color: white; display: inline-block; font-size: 10px; @@ -48,8 +48,8 @@ .edit-icon { position: absolute; - right: 20; - bottom: 5; + right: 20px; + bottom: 5px; display: inline-block; font-size: 10px; cursor: pointer; diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 4cbe01b82..c5948cbbd 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -9,7 +9,7 @@ import { TraceMobx } from '../../../fields/util'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DragManager } from '../../util/DragManager'; -import { undoable } from '../../util/UndoManager'; +import { undoable, UndoManager } from '../../util/UndoManager'; import { ViewBoxBaseComponent } from '../DocComponent'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; @@ -28,6 +28,7 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { private _timeout: NodeJS.Timeout | undefined; private _divRef: HTMLDivElement | null = null; private _disposers: { [key: string]: IReactionDisposer } = {}; + private _liveTextUndo: UndoManager.Batch | undefined; // captured undo batch when typing a new text note into a collection constructor(props: FieldViewProps) { super(props); @@ -168,6 +169,25 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { } }; + setRef = (r: HTMLDivElement | null) => { + this._divRef?.removeEventListener('beforeinput', this.beforeInput); + this._divRef = r; + if (this._divRef) { + this._divRef.addEventListener('beforeinput', this.beforeInput); + + if (DocumentView.SelectOnLoad === this.Document) { + DocumentView.SetSelectOnLoad(undefined); + this._liveTextUndo = FormattedTextBox.LiveTextUndo; + FormattedTextBox.LiveTextUndo = undefined; + this._divRef.focus(); + } + this.fitTextToBox(this._divRef); + if (this.Title) { + this.resetCursor(); + } + } else this._timeout && clearTimeout(this._timeout); + }; + render() { TraceMobx(); const boxParams = this.fitTextToBox(undefined); // this causes mobx to trigger re-render when data changes @@ -220,30 +240,14 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { this.setText(this._divRef?.innerText ?? ''); if (!FormattedTextBox.tryKeepingFocus(e.relatedTarget, () => this._divRef?.focus())) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); - FormattedTextBox.LiveTextUndo?.end(); - FormattedTextBox.LiveTextUndo = undefined; + this._liveTextUndo?.end(); } }} dangerouslySetInnerHTML={{ __html: `<span class="textFitted textFitAlignVert" style="display: inline-block; text-align: center; font-size: 100px; height: 0px;">${this.Title?.startsWith('#') ? '' : (this.Title ?? '')}</span>`, }} contentEditable={this._props.onClickScript?.() ? undefined : true} - ref={r => { - this._divRef?.removeEventListener('beforeinput', this.beforeInput); - this._divRef = r; - if (this._divRef) { - this._divRef.addEventListener('beforeinput', this.beforeInput); - - if (DocumentView.SelectOnLoad === this.Document) { - DocumentView.SetSelectOnLoad(undefined); - this._divRef.focus(); - } - this.fitTextToBox(this._divRef); - if (this.Title) { - this.resetCursor(); - } - } else this._timeout && clearTimeout(this._timeout); - }} + ref={this.setRef} /> </div> </div> diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx index 78c8a686c..d31fadf77 100644 --- a/src/client/views/nodes/LinkBox.tsx +++ b/src/client/views/nodes/LinkBox.tsx @@ -99,6 +99,7 @@ export class LinkBox extends ViewBoxBaseComponent<FieldViewProps>() { } }; + setRef = (r: HTMLDivElement | null) => (this._divRef = r); render() { TraceMobx(); @@ -149,28 +150,18 @@ export class LinkBox extends ViewBoxBaseComponent<FieldViewProps>() { const aid = targetAhyperlinks?.find(alink => container?.contains(alink))?.id ?? targetAhyperlinks?.lastElement()?.id; const bid = targetBhyperlinks?.find(blink => container?.contains(blink))?.id ?? targetBhyperlinks?.lastElement()?.id; if (!aid || !bid) { - setTimeout( - action(() => { - this._forceAnimate += 0.01; - }) - ); + setTimeout(action(() => (this._forceAnimate += 0.01))); return null; } if (foundParent) { setTimeout( - action(() => { - this._forceAnimate += 0.01; - }), + action(() => (this._forceAnimate += 0.01)), 1 ); } - - if (at || bt) - setTimeout( - action(() => { - this._forceAnimate += 0.01; - }) - ); // this forces an update during a transition animation + if (at || bt) { + setTimeout(action(() => (this._forceAnimate += 0.01))); // this forces an update during a transition animation + } const highlight = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Highlighting) as { highlightStyle: string; highlightColor: string; highlightIndex: number; highlightStroke: boolean }; const highlightColor = highlight?.highlightIndex ? highlight?.highlightColor : undefined; const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string; @@ -217,7 +208,7 @@ export class LinkBox extends ViewBoxBaseComponent<FieldViewProps>() { id={this.DocumentView?.().DocUniqueId} className="linkBox-label" tabIndex={-1} - ref={r => (this._divRef = r)} + ref={this.setRef} onPointerDown={e => e.stopPropagation()} onFocus={() => { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, this.dataDoc); diff --git a/src/client/views/nodes/LinkDocPreview.scss b/src/client/views/nodes/LinkDocPreview.scss index 28216394d..7d99247e7 100644 --- a/src/client/views/nodes/LinkDocPreview.scss +++ b/src/client/views/nodes/LinkDocPreview.scss @@ -42,7 +42,7 @@ .linkDocPreview-button { display: inline-flex; - margin: 0; + margin: 0px; margin-right: 3px; border-radius: 50%; pointer-events: auto; diff --git a/src/client/views/nodes/LinkDocPreview.tsx b/src/client/views/nodes/LinkDocPreview.tsx index 5026f52fb..1d6f41c65 100644 --- a/src/client/views/nodes/LinkDocPreview.tsx +++ b/src/client/views/nodes/LinkDocPreview.tsx @@ -40,7 +40,6 @@ export class LinkInfo { LinkInfo._instance = this; makeObservable(this); } - // eslint-disable-next-line no-use-before-define @observable public LinkInfo: Opt<LinkDocPreviewProps> = undefined; public static get Instance() { @@ -251,6 +250,10 @@ export class LinkDocPreview extends ObservableReactComponent<LinkDocPreviewProps ); } + setDocViewRef = (r: DocumentView | null) => { + const targetanchor = this._linkDoc && this._linkSrc && Doc.getOppositeAnchor(this._linkDoc, this._linkSrc); + targetanchor && this._targetDoc !== targetanchor && r?._props.focus?.(targetanchor, {}); + }; @computed get docPreview() { return (!this._linkDoc || !this._targetDoc || !this._linkSrc) && !this._toolTipText ? null : ( <div className="linkDocPreview-inner"> @@ -280,10 +283,7 @@ export class LinkDocPreview extends ObservableReactComponent<LinkDocPreviewProps this._toolTipText ) : ( <DocumentView - ref={r => { - const targetanchor = this._linkDoc && this._linkSrc && Doc.getOppositeAnchor(this._linkDoc, this._linkSrc); - targetanchor && this._targetDoc !== targetanchor && r?._props.focus?.(targetanchor, {}); - }} + ref={this.setDocViewRef} Document={this._targetDoc!} moveDocument={returnFalse} styleProvider={this._props.styleProvider} diff --git a/src/client/views/nodes/LoadingBox.scss b/src/client/views/nodes/LoadingBox.scss index cabd4de05..5a8f49000 100644 --- a/src/client/views/nodes/LoadingBox.scss +++ b/src/client/views/nodes/LoadingBox.scss @@ -26,7 +26,7 @@ } .loadingBox-spinner { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; } } diff --git a/src/client/views/nodes/LoadingBox.tsx b/src/client/views/nodes/LoadingBox.tsx index 325ab18b4..4220ce5f1 100644 --- a/src/client/views/nodes/LoadingBox.tsx +++ b/src/client/views/nodes/LoadingBox.tsx @@ -43,7 +43,7 @@ export class LoadingBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable progress = ''; componentDidMount() { if (!Doc.CurrentlyLoading?.includes(this.Document)) { - this.Document.loadingError = 'Upload interrupted, please try again'; + this.Document.loadingError ??= 'Upload interrupted, please try again'; } else { const updateFunc = async () => { const result = await Networking.QueryYoutubeProgress(StrCast(this.Document[Id])); // We use the guid of the overwriteDoc to track file uploads. diff --git a/src/client/views/nodes/MapBox/MapAnchorMenu.scss b/src/client/views/nodes/MapBox/MapAnchorMenu.scss index c36d98afe..217576203 100644 --- a/src/client/views/nodes/MapBox/MapAnchorMenu.scss +++ b/src/client/views/nodes/MapBox/MapAnchorMenu.scss @@ -6,19 +6,19 @@ } .anchorMenu-highlighter { padding-right: 5px; - .antimodeMenu-button { - padding: 0; - padding: 0; + .antimodeMenu-button { + padding: 0px; + padding: 0px; padding-right: 0px; padding-left: 0px; width: 5px; } } -.anchor-color-preview-button { - width: 25px !important; +.anchor-color-preview-button { + width: 25px !important; .anchor-color-preview { display: flex; - flex-direction: column; + flex-direction: column; padding-right: 3px; width: unset !important; .color-preview { @@ -72,12 +72,11 @@ } } - .MuiInputBase-input{ + .MuiInputBase-input { color: white !important; } - - - .css-1t8l2tu-MuiInputBase-input-MuiOutlinedInput-input.Mui-disabled{ + + .css-1t8l2tu-MuiInputBase-input-MuiOutlinedInput-input.Mui-disabled { -webkit-text-fill-color: #b3b2b2 !important; } @@ -91,7 +90,7 @@ gap: 5px; } - .selected-route-details-container{ + .selected-route-details-container { display: flex; flex-direction: column; gap: 3px; @@ -99,33 +98,25 @@ align-items: flex-start; padding: 5px; } - - } - .customized-marker-container{ + .customized-marker-container { display: flex; flex-direction: column; gap: 10px; - .current-marker-container{ + .current-marker-container { display: flex; align-items: center; gap: 5px; } - .all-markers-container{ + .all-markers-container { display: flex; - align-items: center; + align-items: center; gap: 10px; flex-wrap: wrap; max-width: 400px; } } - - - - } - - diff --git a/src/client/views/nodes/MapBox/MapBox.scss b/src/client/views/nodes/MapBox/MapBox.scss index bd4b51038..89d381070 100644 --- a/src/client/views/nodes/MapBox/MapBox.scss +++ b/src/client/views/nodes/MapBox/MapBox.scss @@ -21,7 +21,7 @@ .mapBox-infoWindow { background-color: white; opacity: 0.75; - padding: 12; + padding: 12px; font-size: 17; } .mapBox-searchbar { @@ -119,7 +119,7 @@ width: 100%; label { - margin-bottom: 0; + margin-bottom: 0px; } .speed-label { @@ -197,12 +197,12 @@ } .mapBox-sidebar { position: absolute; - right: 0; + right: 0px; height: 100%; } .mapBox-sidebar-handle { - top: 0; + top: 0px; //top: calc(50% - 17.5px); // use this to center vertically -- make sure it looks okay for slide views width: 10px; height: 100%; @@ -215,7 +215,7 @@ left: 50%; margin-left: 120px; right: unset !important; - margin-top: -10; + margin-top: -10px; height: max-content; } .searchbox { diff --git a/src/client/views/nodes/MapBox/MapBox.tsx b/src/client/views/nodes/MapBox/MapBox.tsx index a563b7c1b..a279ccc48 100644 --- a/src/client/views/nodes/MapBox/MapBox.tsx +++ b/src/client/views/nodes/MapBox/MapBox.tsx @@ -13,7 +13,7 @@ import { CirclePicker, ColorResult } from 'react-color'; import { Layer, MapProvider, MapRef, Map as MapboxMap, Marker, Source, ViewState, ViewStateChangeEvent } from 'react-map-gl/mapbox'; import { ClientUtils, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; -import { Doc, DocListCast, Field, LinkedTo, Opt, StrListCast } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, LinkedTo, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { RichTextField } from '../../../../fields/RichTextField'; import { DocCast, NumCast, StrCast, toList } from '../../../../fields/Types'; @@ -111,12 +111,12 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // this list contains pushpins and configs @computed get allAnnotations() { return DocListCast(this.dataDoc[this.annotationKey]); } // prettier-ignore - @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); } // prettier-ignore + @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.sidebarKey]); } // prettier-ignore @computed get allPushpins() { return this.allAnnotations.filter(anno => anno.type === DocumentType.PUSHPIN); } // prettier-ignore @computed get allRoutes() { return this.allAnnotations.filter(anno => anno.type === DocumentType.MAPROUTE); } // prettier-ignore @computed get SidebarShown() { return !!this.layoutDoc._layout_showSidebar; } // prettier-ignore @computed get sidebarWidthPercent() { return StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); } // prettier-ignore - @computed get SidebarKey() { return this.fieldKey + '_sidebar'; } // prettier-ignore + @computed get sidebarKey() { return this.fieldKey + '_sidebar'; } // prettier-ignore @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this._props.fieldKey + '_backgroundColor'], '#e4e4e4')); } @@ -260,7 +260,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { removeMapDocument = (doc: Doc | Doc[], annotationKey?: string) => { this.allAnnotations - .filter(anno => toList(doc).includes(DocCast(anno.mapPin))) + .filter(anno => toList(doc).includes(DocCast(anno.mapPin)!)) .forEach(anno => { anno.mapPin = undefined; }); @@ -339,27 +339,19 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { startAnchorDrag = (e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); - - const sourceAnchorCreator = action(() => { - const note = this.getAnchor(true); - if (note && this._selectedPinOrRoute) { - note.latitude = this._selectedPinOrRoute.latitude; - note.longitude = this._selectedPinOrRoute.longitude; - note.map = this._selectedPinOrRoute.map; - } - return note as Doc; - }); - const targetCreator = (annotationOn: Doc | undefined) => { const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); + target.layout_fitWidth = true; DocumentView.SetSelectOnLoad(target); return target; }; + + const sourceAnchorCreator = () => this.getAnchor(true); const docView = this.DocumentView?.(); docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: dragEv => { - if (!dragEv.aborted && dragEv.annoDragData && dragEv.annoDragData.linkSourceDoc && dragEv.annoDragData.dropDocument && dragEv.linkDocument) { + if (!dragEv.aborted && dragEv.annoDragData?.linkSourceDoc && dragEv.annoDragData.dropDocument && dragEv.linkDocument) { dragEv.annoDragData.linkSourceDoc.followLinkToggle = dragEv.annoDragData.dropDocument.annotationOn === this.Document; dragEv.annoDragData.linkSourceDoc.followLinkZoom = false; } @@ -368,17 +360,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; createNoteAnnotation = () => { - const createFunc = undoable( - action(() => { - const note = this._sidebarRef.current?.anchorMenuClick(this.getAnchor(true), ['latitude', 'longitude', LinkedTo]); - if (note && this._selectedPinOrRoute) { - note.latitude = this._selectedPinOrRoute.latitude; - note.longitude = this._selectedPinOrRoute.longitude; - note.map = this._selectedPinOrRoute.map; - } - }), - 'create note annotation' - ); + const createFunc = undoable(() => this._sidebarRef.current?.anchorMenuClick(this.getAnchor(true), ['latitude', 'longitude', LinkedTo]), 'create note annotation'); if (!this.layoutDoc.layout_showSidebar) { this.toggleSidebar(); setTimeout(createFunc); @@ -428,14 +410,11 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }; - getView = (doc: Doc, options: FocusViewOptions) => { - if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) { - this.toggleSidebar(); - options.didMove = true; + getView = async (doc: Doc, options: FocusViewOptions) => { + if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { + SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, this.toggleSidebar, doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return undefined; }; /* @@ -476,13 +455,14 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @action deleteSelectedPinOrRoute = undoable(() => { - if (this._selectedPinOrRoute) { + const selPin = DocCast(this._selectedPinOrRoute); + if (selPin) { // Removes filter - Doc.setDocFilter(this.Document, 'latitude', NumCast(this._selectedPinOrRoute.latitude), 'remove'); - Doc.setDocFilter(this.Document, 'longitude', NumCast(this._selectedPinOrRoute.longitude), 'remove'); - Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this._selectedPinOrRoute))}`, 'remove'); + Doc.setDocFilter(this.Document, 'latitude', NumCast(selPin.latitude), 'remove'); + Doc.setDocFilter(this.Document, 'longitude', NumCast(selPin.longitude), 'remove'); + Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(selPin)}`, 'remove'); - this.removePushpinOrRoute(this._selectedPinOrRoute); + this.removePushpinOrRoute(selPin); } MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); @@ -1299,7 +1279,6 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { <SidebarAnnos ref={this._sidebarRef} {...this._props} - fieldKey={this.fieldKey} Doc={this.Document} layoutDoc={this.layoutDoc} dataDoc={this.dataDoc} diff --git a/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx b/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx index e0efab576..0beefcb67 100644 --- a/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx +++ b/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx @@ -145,7 +145,7 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> removeMapDocument = (docsIn: Doc | Doc[], annotationKey?: string) => { const docs = toList(docsIn); this.allAnnotations - .filter(anno => docs.includes(DocCast(anno.mapPin))) + .filter(anno => docs.includes(DocCast(anno.mapPin)!)) .forEach(anno => { anno.mapPin = undefined; }); @@ -224,6 +224,12 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> startAnchorDrag = (e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); + const targetCreator = (annotationOn: Doc | undefined) => { + const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); + target.layout_fitWidth = true; + DocumentView.SetSelectOnLoad(target); + return target; + }; const sourceAnchorCreator = action(() => { const note = this.getAnchor(true); @@ -235,11 +241,6 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> return note as Doc; }); - const targetCreator = (annotationOn: Doc | undefined) => { - const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); - DocumentView.SetSelectOnLoad(target); - return target; - }; const docView = this.DocumentView?.(); docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { @@ -362,22 +363,22 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> @action deselectPin = () => { - if (this.selectedPin) { + const selPin = DocCast(this.selectedPin); + if (selPin) { // Removes filter - Doc.setDocFilter(this.Document, 'latitude', NumCast(this.selectedPin.latitude), 'remove'); - Doc.setDocFilter(this.Document, 'longitude', NumCast(this.selectedPin.longitude), 'remove'); - Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this.selectedPin))}`, 'remove'); + Doc.setDocFilter(this.Document, 'latitude', NumCast(selPin.latitude), 'remove'); + Doc.setDocFilter(this.Document, 'longitude', NumCast(selPin.longitude), 'remove'); + Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(selPin)}`, 'remove'); - const temp = this.selectedPin; if (!this._unmounting) { - this._bingMap.current.entities.remove(this.map_docToPinMap.get(temp)); + this._bingMap.current.entities.remove(this.map_docToPinMap.get(selPin)); } - const newpin = new this.MicrosoftMaps.Pushpin(new this.MicrosoftMaps.Location(temp.latitude, temp.longitude)); - this.MicrosoftMaps.Events.addHandler(newpin, 'click', () => this.pushpinClicked(temp as Doc)); + const newpin = new this.MicrosoftMaps.Pushpin(new this.MicrosoftMaps.Location(selPin.latitude, selPin.longitude)); + this.MicrosoftMaps.Events.addHandler(newpin, 'click', () => this.pushpinClicked(selPin)); if (!this._unmounting) { this._bingMap.current.entities.push(newpin); } - this.map_docToPinMap.set(temp, newpin); + this.map_docToPinMap.set(selPin, newpin); this.selectedPin = undefined; this.bingSearchBarContents = this.Document.map; } @@ -388,9 +389,7 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> this.toggleSidebar(); options.didMove = true; } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res)); }; /* * Pushpin onclick @@ -535,13 +534,14 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> @action deleteSelectedPin = undoable(() => { - if (this.selectedPin) { + const selPin = this.selectedPin; + if (selPin) { // Removes filter - Doc.setDocFilter(this.Document, 'latitude', NumCast(this.selectedPin.latitude), 'remove'); - Doc.setDocFilter(this.Document, 'longitude', NumCast(this.selectedPin.longitude), 'remove'); - Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this.selectedPin))}`, 'remove'); + Doc.setDocFilter(this.Document, 'latitude', NumCast(selPin.latitude), 'remove'); + Doc.setDocFilter(this.Document, 'longitude', NumCast(selPin.longitude), 'remove'); + Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(selPin)}`, 'remove'); - this.removePushpin(this.selectedPin); + this.removePushpin(selPin); } MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); @@ -638,7 +638,10 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> this._disposers.highlight = reaction( () => this.allAnnotations.map(doc => doc[Highlight]), () => { - const allConfigPins = this.allAnnotations.map(doc => ({ doc, pushpin: DocCast(doc.mapPin) })).filter(pair => pair.pushpin); + const allConfigPins = this.allAnnotations + .map(doc => ({ doc, pushpin: DocCast(doc.mapPin) })) + .filter(pair => pair.pushpin) + .map(pair => ({ doc: pair.doc, pushpin: pair.pushpin! })); allConfigPins.forEach(({ pushpin }) => { if (!pushpin[Highlight] && this.map_pinHighlighted.get(pushpin)) { this.recolorPin(pushpin); diff --git a/src/client/views/nodes/PDFBox.scss b/src/client/views/nodes/PDFBox.scss index 44013a96d..c9edb2180 100644 --- a/src/client/views/nodes/PDFBox.scss +++ b/src/client/views/nodes/PDFBox.scss @@ -17,8 +17,8 @@ height: 100%; z-index: 1; pointer-events: none; - top: 0; - left: 0; + top: 0px; + left: 0px; // glr: This should really be the same component as text and PDFs .pdfBox-sidebarBtn { @@ -72,16 +72,16 @@ align-items: center; height: 20px; background: none; - padding: 0; + padding: 0px; position: absolute; pointer-events: all; color: white; - bottom: 0; - right: 0; + bottom: 0px; + right: 0px; .pdfBox-overlayButton-arrow { - width: 0; - height: 0; + width: 0px; + height: 0px; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 15px solid #121721; @@ -122,8 +122,8 @@ .pdfBox-settingsCont { position: absolute; - right: 0; - top: 3; + right: 0px; + top: 3px; pointer-events: all; .pdfBox-settingsButton { @@ -133,11 +133,11 @@ align-items: center; height: 20px; background: none; - padding: 0; + padding: 0px; .pdfBox-settingsButton-arrow { - width: 0; - height: 0; + width: 0px; + height: 0px; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 15px solid #121721; @@ -189,7 +189,7 @@ width: calc(100% - 40px); height: 20px; background: #121721; - bottom: 0; + bottom: 0px; display: flex; justify-content: center; align-items: center; @@ -198,7 +198,7 @@ pointer-events: all; .pdfBox-searchBar { - width: 70%; + width: calc(100% - 120px); // less size of search buttons font-size: 14px; } } @@ -253,13 +253,13 @@ .pdfBox-container { position: absolute; transform-origin: top left; - top: 0; + top: 0px; } .pdfBox-sidebarContainer { position: absolute; height: 100%; - right: 0; - top: 0; + right: 0px; + top: 0px; } .pdfBox-interactive { @@ -290,7 +290,7 @@ } .pdfBox-settingsButton-arrow { - height: 60; + height: 60px; border-top: 30px solid transparent; border-bottom: 30px solid transparent; border-right: 30px solid #121721; diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 4ecbd65b6..34211fa07 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -10,7 +10,7 @@ import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; import { ComputedField } from '../../../fields/ScriptField'; -import { Cast, FieldValue, NumCast, StrCast, toList } from '../../../fields/Types'; +import { Cast, DocCast, FieldValue, NumCast, StrCast, toList } from '../../../fields/Types'; import { ImageField, PdfField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; @@ -27,13 +27,15 @@ import { Colors } from '../global/globalEnums'; import { PDFViewer } from '../pdf/PDFViewer'; import { PinDocView, PinProps } from '../PinFuncs'; import { SidebarAnnos } from '../SidebarAnnos'; -import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import { ImageBox } from './ImageBox'; import { OpenWhere } from './OpenWhere'; import './PDFBox.scss'; import { CreateImage } from './WebBoxRenderer'; +import { gptAPICall } from '../../apis/gpt/GPT'; +import { List } from '../../../fields/List'; +import { GPTCallType } from '../../apis/gpt/GPT'; @observer export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @@ -57,6 +59,9 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable private _pdf: Opt<Pdfjs.PDFDocumentProxy> = undefined; @observable private _pageControls = false; + @computed get sidebarKey() { + return this.fieldKey + '_sidebar'; + } @computed get pdfUrl() { return Cast(this.dataDoc[this._props.fieldKey], PdfField); } @@ -77,6 +82,36 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } } + autoTag = async () => { + if (!this.Document.$tags_chat && this._pdf) { + if (!this.dataDoc.text) { + // 1) Extract text from the first few pages (e.g., first 2 pages) + const maxPages = Math.min(2, this._pdf.numPages); + const promises: Promise<string>[] = []; + for (let pageNum = 1; pageNum <= maxPages; pageNum++) { + promises.push( + this._pdf + .getPage(pageNum) + .then(page => page.getTextContent()) + .then(content => content.items.map(item => ('str' in item ? item.str : '')).join(' ')) + ); + } + this.dataDoc.text = (await Promise.all(promises)).join(' '); + } + + const text = StrCast(this.dataDoc.text).trim().slice(0, 2000); + if (text) { + // 2) Ask GPT to classify and provide descriptive tags, then normalize the results + const label = await gptAPICall(`"${text}"`, GPTCallType.CLASSIFYTEXTFULL).then(raw => raw.trim().toUpperCase()); + + this.Document.$tags_chat = new List<string>(label.split(/\s+/)); + + // 4) Show tags in layout + this.Document._layout_showTags = true; + } + } + }; + replaceCanvases = (oldDiv: HTMLElement, newDiv: HTMLElement) => { if (oldDiv.childNodes) { for (let i = 0; i < oldDiv.childNodes.length; i++) { @@ -233,14 +268,11 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return this._pdfViewer?.scrollFocus(anchor, NumCast(anchor.y, NumCast(anchor.config_scrollTop)), options); }; - getView = (doc: Doc, options: FocusViewOptions) => { - if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) { - options.didMove = true; - this.toggleSidebar(false); + getView = async (doc: Doc, options: FocusViewOptions) => { + if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { + SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, () => this.toggleSidebar(false), doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return undefined; }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { @@ -406,7 +438,15 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { onKeyDown={e => ([KeyCodes.BACKSPACE, KeyCodes.DELETE].includes(e.keyCode) ? e.stopPropagation() : true)} onPointerDown={e => e.stopPropagation()} style={{ display: this._props.isContentActive() ? 'flex' : 'none' }}> - <div className="pdfBox-overlayCont" onPointerDown={e => e.stopPropagation()} style={{ left: `${this._searching ? 0 : 100}%` }}> + <div + className="pdfBox-overlayCont" + onPointerDown={e => e.stopPropagation()} + style={{ + transformOrigin: 'bottom left', + transform: `scale(${this._props.DocumentView?.().UIBtnScaling || 1})`, + width: `${100 / (this._props.DocumentView?.().UIBtnScaling || 1)}%`, + left: `${this._searching ? 0 : 100}%`, + }}> <button type="button" className="pdfBox-overlayButton" title={searchTitle} /> <input className="pdfBox-searchBar" @@ -435,17 +475,25 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { type="button" className="pdfBox-overlayButton" title={searchTitle} + style={{ + transformOrigin: 'bottom right', + transform: `scale(${this._props.DocumentView?.().UIBtnScaling || 1})`, + }} onClick={action(() => { this._searching = !this._searching; this.search('', true, true); })}> - <div className="pdfBox-overlayButton-arrow" onPointerDown={e => e.stopPropagation()} /> <div className="pdfBox-overlayButton-iconCont" onPointerDown={e => e.stopPropagation()}> <FontAwesomeIcon icon={this._searching ? 'times' : 'search'} size="lg" /> </div> </button> - <div className="pdfBox-pageNums"> + <div + className="pdfBox-pageNums" + style={{ + transformOrigin: 'top left', + transform: `scale(${this._props.DocumentView?.().UIBtnScaling || 1})`, + }}> <input value={curPage} style={{ width: `${curPage > 99 ? 4 : 3}ch`, pointerEvents: 'all' }} @@ -649,19 +697,11 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const pdfView = !this._pdf ? null : this.renderPdfView; const href = this.pdfUrl?.url.href; if (!pdfView && href) { - if (PDFBox.pdfcache.get(href)) - setTimeout( - action(() => { - this._pdf = PDFBox.pdfcache.get(href); - }) - ); + if (PDFBox.pdfcache.get(href)) setTimeout(action(() => (this._pdf = PDFBox.pdfcache.get(href)))); else { - if (!PDFBox.pdfpromise.get(href)) PDFBox.pdfpromise.set(href, Pdfjs.getDocument(href).promise); - PDFBox.pdfpromise.get(href)?.then( - action(pdf => { - PDFBox.pdfcache.set(href, (this._pdf = pdf)); - }) - ); + const pdfPromise = PDFBox.pdfpromise.get(href) ?? Pdfjs.getDocument(href).promise; + PDFBox.pdfpromise.set(href, pdfPromise); + pdfPromise.then(action(pdf => PDFBox.pdfcache.set(href, (this._pdf = pdf)))); } } return pdfView ?? this.renderTitleBox; diff --git a/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss b/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss index ac2c611c7..78aa526bf 100644 --- a/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss +++ b/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss @@ -29,8 +29,8 @@ .wedge { pointer-events: none; position: absolute; - left: 0; - top: 0; + left: 0px; + top: 0px; } } diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.scss b/src/client/views/nodes/RecordingBox/ProgressBar.scss index 28ad25ffa..ec01f0241 100644 --- a/src/client/views/nodes/RecordingBox/ProgressBar.scss +++ b/src/client/views/nodes/RecordingBox/ProgressBar.scss @@ -1,36 +1,34 @@ - .progressbar { - touch-action: none; - vertical-align: middle; - text-align: center; - - align-items: center; - cursor: default; - - - position: absolute; - display: flex; - justify-content: flex-start; - bottom: 2px; - width: 99%; - height: 30px; - background-color: gray; - - &.done { - top: 0; - width: 0px; - height: 5px; - background-color: red; - z-index: 2; - } - - &.mark { - top: 0; - background-color: transparent; - border-right: 2px solid white; - z-index: 3; - pointer-events: none; - } + touch-action: none; + vertical-align: middle; + text-align: center; + + align-items: center; + cursor: default; + + position: absolute; + display: flex; + justify-content: flex-start; + bottom: 2px; + width: 99%; + height: 30px; + background-color: gray; + + &.done { + top: 0px; + width: 0px; + height: 5px; + background-color: red; + z-index: 2; + } + + &.mark { + top: 0px; + background-color: transparent; + border-right: 2px solid white; + z-index: 3; + pointer-events: none; + } } .progressbar-disabled { @@ -43,37 +41,41 @@ // citation: https://codepen.io/_Master_/pen/PRdjmQ @keyframes blinker { - from {opacity: 1.0;} - to {opacity: 0.0;} + from { + opacity: 1; + } + to { + opacity: 0; + } } .blink { - text-decoration: blink; - animation-name: blinker; - animation-duration: 0.6s; - animation-iteration-count:infinite; - animation-timing-function:ease-in-out; - animation-direction: alternate; + text-decoration: blink; + animation-name: blinker; + animation-duration: 0.6s; + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; + animation-direction: alternate; } .segment { - border: 3px solid black; - background-color: red; - margin: 1px; - padding: 0; - cursor: pointer; - transition-duration: .5s; - user-select: none; - - vertical-align: middle; - text-align: center; + border: 3px solid black; + background-color: red; + margin: 1px; + padding: 0px; + cursor: pointer; + transition-duration: 0.5s; + user-select: none; + + vertical-align: middle; + text-align: center; } .segment-expanding { -border-color: red; - background-color: white; - transition-duration: 0s; - opacity: .75; - pointer-events: none; + border-color: red; + background-color: white; + transition-duration: 0s; + opacity: 0.75; + pointer-events: none; } .segment-expanding:hover { @@ -82,10 +84,10 @@ border-color: red; } .segment-disabled { - pointer-events: none; - opacity: 0.5; - transition-duration: 0s; - /* Hide the text. */ + pointer-events: none; + opacity: 0.5; + transition-duration: 0s; + /* Hide the text. */ text-indent: 100%; white-space: nowrap; overflow: hidden; @@ -99,25 +101,26 @@ border-color: red; } .segment:first-child { - margin-left: 2px; + margin-left: 2px; } .segment:last-child { - margin-right: 2px; + margin-right: 2px; } .segment:hover { background-color: white; } -.segment:hover, .segment-selected { - margin: 0px; - border: 4px solid red; - border-radius: 2px; +.segment:hover, +.segment-selected { + margin: 0px; + border: 4px solid red; + border-radius: 2px; } .segment-selected { - border: 4px solid #202020; - background-color: red; - opacity: .75; - cursor: grabbing; + border: 4px solid #202020; + background-color: red; + opacity: 0.75; + cursor: grabbing; } diff --git a/src/client/views/nodes/RecordingBox/RecordingView.scss b/src/client/views/nodes/RecordingBox/RecordingView.scss index f2d5a980d..15b48c111 100644 --- a/src/client/views/nodes/RecordingBox/RecordingView.scss +++ b/src/client/views/nodes/RecordingBox/RecordingView.scss @@ -28,7 +28,7 @@ video { justify-content: center; // overflow: hidden; border-radius: 10px; - margin: 0; + margin: 0px; } .video-wrapper:hover .controls { @@ -108,7 +108,7 @@ video { .timer { font-size: 15px; color: white; - margin: 0; + margin: 0px; } .dot { @@ -148,7 +148,7 @@ video { height: 80%; width: 80%; align-self: center; - margin: 0; + margin: 0px; &:hover { height: 85%; @@ -163,7 +163,7 @@ video { height: 70%; width: 70%; align-self: center; - margin: 0; + margin: 0px; // &:hover { // width: 40px; @@ -178,8 +178,8 @@ video { flex-direction: row; align-content: center; position: relative; - top: 0; - bottom: 0; + top: 0px; + bottom: 0px; &.video-edit-wrapper { // right: 50% - 15; diff --git a/src/client/views/nodes/ScreenshotBox.scss b/src/client/views/nodes/ScreenshotBox.scss index 1e9b64a0b..1714d87c2 100644 --- a/src/client/views/nodes/ScreenshotBox.scss +++ b/src/client/views/nodes/ScreenshotBox.scss @@ -32,10 +32,10 @@ .screenshotBox-uiButtons { position: absolute; - right: 25; - top: 0; - width: 22; - height: 25; + right: 25px; + top: 0px; + width: 22px; + height: 25px; .screenshotBox-recorder { color: white; diff --git a/src/client/views/nodes/ScreenshotBox.tsx b/src/client/views/nodes/ScreenshotBox.tsx index 603dcad5c..4677e0e61 100644 --- a/src/client/views/nodes/ScreenshotBox.tsx +++ b/src/client/views/nodes/ScreenshotBox.tsx @@ -171,19 +171,21 @@ export class ScreenshotBox extends ViewBoxAnnotatableComponent<FieldViewProps>() ContextMenu.Instance.addItem({ description: 'Options...', subitems, icon: 'video' }); }; + setRef = (r: HTMLVideoElement | null) => { + this._videoRef = r; + setTimeout(() => { + if (this.layoutDoc.mediaState === mediaState.PendingRecording && this._videoRef) { + this.toggleRecording(); + } + }, 100); + }; + @computed get content() { return ( <video className="videoBox-content" key="video" - ref={r => { - this._videoRef = r; - setTimeout(() => { - if (this.layoutDoc.mediaState === mediaState.PendingRecording && this._videoRef) { - this.toggleRecording(); - } - }, 100); - }} + ref={this.setRef} autoPlay={this._screenCapture} style={{ width: this._screenCapture ? '100%' : undefined, height: this._screenCapture ? '100%' : undefined }} onCanPlay={this.videoLoad} diff --git a/src/client/views/nodes/ScriptingBox.scss b/src/client/views/nodes/ScriptingBox.scss index 9789da55a..de70dbe74 100644 --- a/src/client/views/nodes/ScriptingBox.scss +++ b/src/client/views/nodes/ScriptingBox.scss @@ -82,14 +82,14 @@ } .rta__autocomplete--top { - margin-top: 0; + margin-top: 0px; margin-bottom: 1em; max-height: 100px; } .rta__list { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; background: #fff; border: 1px solid #dfe2e5; border-radius: 3px; diff --git a/src/client/views/nodes/TaskBox.scss b/src/client/views/nodes/TaskBox.scss new file mode 100644 index 000000000..beee58697 --- /dev/null +++ b/src/client/views/nodes/TaskBox.scss @@ -0,0 +1,132 @@ +.task-manager-container { + color-scheme: light; + display: flex; + flex-direction: column; + padding: 8px; + gap: 10px; + width: 100%; + height: 100%; + box-sizing: border-box; +} + +.task-manager-title { + width: 100%; + font-size: 1.25rem; + font-weight: 600; + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 6px; + box-sizing: border-box; +} + +.task-manager-description { + width: 100%; + font-size: 1rem; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 6px; + min-height: 40px; + box-sizing: border-box; + vertical-align: top; + text-align: start; + resize: none; + line-height: 1.4; + resize: none; + flex-grow: 1; +} + +.task-manager-checkboxes { + display: flex; + align-items: center; + gap: 16px; +} + +.task-manager-allday, +.task-manager-complete { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.95rem; +} + +.task-manager-times { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.task-manager-times label { + display: flex; + flex-direction: column; + font-size: 0.9rem; + font-weight: 500; + gap: 4px; +} + +input[type='datetime-local'] { + width: 100%; + font-size: 0.9rem; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 6px; + box-sizing: border-box; +} + +.task-manager-checkboxes { + display: flex; + flex-wrap: wrap; /* allows wrapping on small screens */ + align-items: center; + gap: 16px; + row-gap: 8px; /* optional: tighter vertical spacing if it wraps */ +} + +.task-manager-google { + align-self: flex-start; + width: auto; + font-size: 0.85rem; + padding: 6px 12px; + border-radius: 6px; + background-color: #5e88c8; + color: white; + border: none; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + + &:hover { + background-color: #4773b0; // darker shade of your base blue + color: white; + transform: scale(1.01); // subtle hover feel without real size change + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} + +.task-box-blur-wrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + min-width: 0; +} + +.task-manager-button-row { + display: flex; + flex-direction: row; + gap: 8px; +} + +.task-manager-delete { + @extend .task-manager-google; + background-color: #182430; + + &:hover { + background-color: #000000; + } +} diff --git a/src/client/views/nodes/TaskBox.tsx b/src/client/views/nodes/TaskBox.tsx new file mode 100644 index 000000000..ed5982c55 --- /dev/null +++ b/src/client/views/nodes/TaskBox.tsx @@ -0,0 +1,670 @@ +import { action, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { DateField } from '../../../fields/DateField'; +import { BoolCast, DateCast, DocCast, NumCast, StrCast } from '../../../fields/Types'; +import { GoogleAuthenticationManager } from '../../apis/GoogleAuthenticationManager'; +import { Docs } from '../../documents/Documents'; +import { DocumentType } from '../../documents/DocumentTypes'; +import { ViewBoxBaseComponent } from '../DocComponent'; +import { FieldView, FieldViewProps } from './FieldView'; +import './TaskBox.scss'; +import { DocumentDecorations } from '../DocumentDecorations'; +import { Doc } from '../../../fields/Doc'; +import { DocumentView } from './DocumentView'; + +/** + * TaskBox class for adding task information + completing tasks + */ +@observer +export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { + _googleTaskCreateDisposer?: IReactionDisposer; + _heightDisposer?: IReactionDisposer; + _widthDisposer?: IReactionDisposer; + @observable _needsSync = false; // Whether the task needs to be synced with Google Tasks + @observable _syncing = false; // Whether the task is currently syncing with Google Tasks + private _isFocused = false; // Whether the task box is currently focused + + // contains the last synced task information + private _lastSyncedTask: { + title: string; + text: string; + due?: string; + completed: boolean; + deleted?: boolean; + } = { + title: '', + text: '', + due: '', + completed: false, + deleted: false, + }; + + /** + * Getter for needsSync + */ + get needsSync() { + return this._needsSync; + } + + /** + * Constructor for the task box + * @param props - props containing the document reference + */ + constructor(props: FieldViewProps) { + super(props); + makeObservable(this); + } + + /** + * Return the JSX string that will create this component + * @param fieldStr the Doc field that contains the primary data for this component + * @returns + */ + public static LayoutString(fieldStr: string) { + return FieldView.LayoutString(TaskBox, fieldStr); + } + + /** + * Method to update the task description + * @param e - event of changing the description box input + */ + @action + updateText = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { + this.Document[this.fieldKey] = e.target.value; + }; + + /** + * Method to update the task title + * @param e - event of changing the title box input + */ + @action + updateTitle = (e: React.ChangeEvent<HTMLInputElement>) => { + this.Document.title = e.target.value; + }; + + /** + * Method to update the all day status + * @param e - event of changing the all day checkbox + */ + @action + updateAllDay = (e: React.ChangeEvent<HTMLInputElement>) => { + this.Document.$task_allDay = e.target.checked; + + if (e.target.checked) { + delete this.Document.$task_startTime; + delete this.Document.$task_endTime; + } + + this.setTaskDateRange(); + }; + + /** + * Method to update the task start time + * @param e - event of changing the start time input + */ + @action + updateStart = (e: React.ChangeEvent<HTMLInputElement>) => { + const newStart = new Date(e.target.value); + + this.Document.$task_startTime = new DateField(newStart); + + const endDate = this.Document.$task_endTime instanceof DateField ? this.Document.$task_endTime.date : undefined; + if (endDate && newStart > endDate) { + // Alert user + alert('Start time cannot be after end time. End time has been adjusted.'); + + // Fix end time + const adjustedEnd = new Date(newStart.getTime() + 60 * 60 * 1000); + this.Document.$task_endTime = new DateField(adjustedEnd); + } + + this.setTaskDateRange(); + }; + + /** + * Method to update the task end time + * @param e - event of changing the end time input + */ + @action + updateEnd = (e: React.ChangeEvent<HTMLInputElement>) => { + const newEnd = new Date(e.target.value); + + this.Document.$task_endTime = new DateField(newEnd); + + const startDate = this.Document.$task_startTime instanceof DateField ? this.Document.$task_startTime.date : undefined; + if (startDate && newEnd < startDate) { + // Alert user + alert('End time cannot be before start time. Start time has been adjusted.'); + + // Fix start time + const adjustedStart = new Date(newEnd.getTime() - 60 * 60 * 1000); + this.Document.$task_startTime = new DateField(adjustedStart); + } + + this.setTaskDateRange(); + }; + + /** + * Method to update the task date range + */ + @action + setTaskDateRange() { + const doc = this.Document; + + if (doc.$task_allDay) { + const range = StrCast(doc.$task_dateRange).split('|'); + const dateStr = range[0] ?? new Date().toISOString().split('T')[0]; // default to today + + doc.$task_dateRange = `${dateStr}|${dateStr}`; + doc.$task_allDay = true; + } else { + const startField = doc.$task_startTime; + const endField = doc.$task_endTime; + const startDate = startField instanceof DateField ? startField.date : null; + const endDate = endField instanceof DateField ? endField.date : null; + + if (startDate && endDate && !isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) { + doc.$task_dateRange = `${startDate.toISOString()}|${endDate.toISOString()}`; + doc.$task_allDay = false; + } + } + } + + /** + * Method to set task's completion status + * @param e - event of changing the "completed" input checkbox + */ + + @action + toggleComplete = (e: React.ChangeEvent<HTMLInputElement>) => { + this.Document.$task_completed = e.target.checked; + }; + + /** + * Computes due date for the task (for Google Tasks API) + * @returns - a string representing the due date in ISO format, or undefined if no valid date is found + */ + private computeDueDate(): string | undefined { + const doc = this.Document; + let due: string | undefined; + const allDay = !!doc.$task_allDay; + + if (allDay) { + const rawRange = StrCast(doc.$task_dateRange); + const datePart = rawRange.split('|')[0]; + + if (datePart && !isNaN(new Date(datePart).getTime())) { + // Set time to midnight UTC to represent the start of the all-day event + const baseDate = datePart.includes('T') ? datePart : datePart + 'T00:00:00Z'; + due = new Date(baseDate).toISOString(); + } else { + due = undefined; + } + } else if (doc.$task_endTime instanceof DateField && doc.$task_endTime.date) { + due = doc.$task_endTime.date.toISOString(); + } else if (doc.$task_startTime instanceof DateField && doc.$task_startTime.date) { + due = doc.$task_startTime.date.toISOString(); + } else { + due = undefined; + } + + return due; + } + + /** + * Builds the body for the Google Tasks API request + * @returns - an object containing the task details + */ + + private buildGoogleTaskBody(): Record<string, string | boolean | undefined> { + const doc = this.Document; + const title = StrCast(doc.title, 'Untitled Task'); + const notes = StrCast(doc[this.fieldKey]); + const due = this.computeDueDate(); + const completed = !!doc.$task_completed; + + const body: Record<string, string | boolean | undefined> = { + title, + notes, + due, + status: completed ? 'completed' : 'needsAction', + completed: completed ? new Date().toISOString() : undefined, + }; + + if (doc.$dashDeleted === true) { + body.deleted = true; + } else if (doc.$dashDeleted === false) { + body.deleted = false; + } + + return body; + } + + /** + * Handles the focus event for the task box (for auto-syncing) + */ + + handleFocus = () => { + if (!this._isFocused) { + this._isFocused = true; + this.syncWithGoogleTaskBidirectional(true); // silent sync + } + }; + + /** + * Handles the blur event for the task box (for auto-syncing) + * @param e - the focus event + */ + handleBlur = (e: React.FocusEvent<HTMLDivElement>) => { + // Check if focus is moving outside this component + if (!e.currentTarget.contains(e.relatedTarget)) { + this._isFocused = false; + this.syncWithGoogleTaskBidirectional(true); + } + }; + + /** + * Method to sync the task with Google Tasks bidirectionally + * (update Dash from Google and vice versa, based on which is newer) + * @param silent - whether to suppress UI prompts to connect to Google (default: false) + * @returns - a promise that resolves to true if sync was successful, false otherwise + */ + + syncWithGoogleTaskBidirectional = async (silent = false): Promise<boolean> => { + const doc = this.Document; + let token: string | undefined; + try { + token = silent ? await GoogleAuthenticationManager.Instance.fetchAccessTokenSilently() : await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); + } catch (err) { + console.warn('Google auth failed:', err); + return false; + } + + if (!token) { + if (!silent) { + const listener = () => { + window.removeEventListener('focusin', listener); + if (confirm('✅ Authorization complete. Try syncing the task again?')) { + // try syncing again + this.syncWithGoogleTaskBidirectional(); + } + window.removeEventListener('focusin', listener); + }; + setTimeout(() => window.addEventListener('focusin', listener), 100); + } + return false; + } + + if (!doc.$googleTaskId) return false; + + runInAction(() => { + this._syncing = true; + }); + + try { + // Fetch current version of Google Task + const response = await fetch(`/googleTasks/${doc.$googleTaskId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + credentials: 'include', + }); + + const googleTask = await response.json(); + const googleUpdated = new Date(googleTask.updated); + const dashUpdated = new Date(StrCast(doc.$task_lastSyncedAt)); + + const dashChanged = + StrCast(doc.title) !== this._lastSyncedTask.title || + StrCast(doc[this.fieldKey]) !== this._lastSyncedTask.text || + this.computeDueDate() !== this._lastSyncedTask.due || + !!doc.$task_completed !== this._lastSyncedTask.completed || + !!doc.$dashDeleted !== this._lastSyncedTask.deleted; + + if (googleUpdated > dashUpdated && !dashChanged) { + // Google version is newer — update Dash + runInAction(() => { + doc.title = googleTask.title ?? doc.title; + doc[this.fieldKey] = googleTask.notes ?? doc[this.fieldKey]; + doc.$task_completed = googleTask.status === 'completed'; + + if (googleTask.due && googleTask.due.split('T')[0] !== this.computeDueDate()?.split('T')[0]) { + const dueDate = new Date(googleTask.due); + doc.$task_allDay = true; + doc.$task_dateRange = `${dueDate.toISOString().split('T')[0]}|${dueDate.toISOString().split('T')[0]}`; + } + + doc.$task_lastSyncedAt = googleTask.updated; + this._lastSyncedTask = { + title: StrCast(doc.title), + text: StrCast(doc[this.fieldKey]), + due: this.computeDueDate(), + completed: !!doc.$task_completed, + deleted: !!doc.$dashDeleted, + }; + this._needsSync = false; + }); + + console.log('Pulled newer version from Google'); + return true; + } else if (googleUpdated <= dashUpdated && !dashChanged) { + console.log('No changes to sync'); + return true; + } else { + // Dash version is newer — push update to Google + const body = this.buildGoogleTaskBody(); + const res = await fetch(`/googleTasks/${doc.$googleTaskId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + credentials: 'include', + body: JSON.stringify(body), + }); + + const result = await res.json(); + if (result?.id) { + doc.$task_lastSyncedAt = new Date().toISOString(); + this._lastSyncedTask = { + title: StrCast(doc.title), + text: StrCast(doc[this.fieldKey]), + due: this.computeDueDate(), + completed: !!doc.$task_completed, + deleted: !!doc.$dashDeleted, + }; + this._needsSync = false; + console.log('Pushed newer version to Google'); + return true; + } else { + console.warn('❌ Push failed:', result); + return false; + } + } + } catch (err) { + console.error('❌ Sync error:', err); + return false; + } finally { + runInAction(() => { + this._syncing = false; + }); + } + }; + + /** + * Method to set up the task box on mount + */ + componentDidMount() { + this.setTaskDateRange(); + const doc = this.Document; + + // adding task on creation to google + (async () => { + if (!doc.$googleTaskId && doc.title) { + try { + const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); + if (!token) return; + const body = this.buildGoogleTaskBody(); + + const res = await fetch('/googleTasks/create', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const result = await res.json(); + if (result?.id) { + doc.$googleTaskId = result.id; + console.log('✅ Google Task created on mount:', result); + } else { + console.warn('❌ Google Task creation failed:', result); + } + } catch (err) { + console.warn('❌ Error creating Google Task:', err); + } + } else if (doc.$googleTaskId) { + await this.syncWithGoogleTaskBidirectional(); + } + })(); + + this._heightDisposer = reaction( + () => NumCast(doc._height), + height => { + const minHeight = NumCast(doc.height_min); + if (height < minHeight) { + doc._height = minHeight; + } + } + ); + + this._widthDisposer = reaction( + () => NumCast(doc._width), + width => { + const minWidth = NumCast(doc.width_min); + if (width < minWidth) { + doc._width = minWidth; + } + } + ); + + runInAction(() => { + const completed = BoolCast(doc.$task_completed); + const due = this.computeDueDate(); + + this._lastSyncedTask = { + title: StrCast(doc.title), + text: StrCast(doc[this.fieldKey]), + due, + completed, + deleted: !!doc.$dashDeleted, + }; + this._needsSync = false; + }); + + if (this.Document.$dashDeleted) { + runInAction(() => { + this.Document.$dashDeleted = false; + }); + } + + this._googleTaskCreateDisposer = reaction( + () => { + const completed = BoolCast(doc.$task_completed); + const due = this.computeDueDate(); + const dashDeleted = !!doc.$dashDeleted; + + return { title: StrCast(doc.title), text: StrCast(doc[this.fieldKey]), completed, due, dashDeleted }; + }, + ({ title, text, completed, due, dashDeleted }) => { + this._needsSync = title !== this._lastSyncedTask.title || text !== this._lastSyncedTask.text || due !== this._lastSyncedTask.due || completed !== this._lastSyncedTask.completed || dashDeleted !== this._lastSyncedTask.deleted; + }, + { fireImmediately: true } + ); + } + + /** + * Method to clean up the task box on unmount + */ + componentWillUnmount() { + const doc = this.Document; + this._googleTaskCreateDisposer?.(); + this._heightDisposer?.(); + this._widthDisposer?.(); + } + + /** + * Method to handle task deletion + * @returns - a promise that resolves when the task is deleted + */ + handleDeleteTask = async () => { + const doc = this.Document; + if (!doc.$googleTaskId) return; + if (!window.confirm('Are you sure you want to delete this task?')) return; + + doc.$dashDeleted = true; + this._needsSync = true; + + try { + const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); + if (!token) return; + + await fetch(`/googleTasks/${doc.$googleTaskId}`, { + method: 'DELETE', + credentials: 'include', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const view = DocumentView.getDocumentView(this.Document); + if (view) { + DocumentView.SelectView(view, false); // select document + DocumentDecorations.Instance?.onCloseClick?.(true); // simulate clicking the close button + } + + // Remove the task from the recently closed list + Doc.MyRecentlyClosed && Doc.RemoveDocFromList(Doc.MyRecentlyClosed, undefined, this.Document); + console.log(`✅ Deleted Google Task ${doc.$googleTaskId}`); + } catch (err) { + console.warn('❌ Failed to delete Google Task:', err); + } + }; + + /** + * Method to render the task box + * @returns - HTML with taskbox components + */ + + render() { + function toLocalDateTimeString(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, '0'); + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + + const doc = this.Document; + + const taskDesc = StrCast(doc[this.fieldKey]); + const taskTitle = StrCast(doc.title); + const allDay = !!doc.$task_allDay; + const due = this.computeDueDate(); + const isCompleted = !!this.Document.$task_completed; + + const startTime = DateCast(doc.$task_startTime) ? toLocalDateTimeString(DateCast(doc.$task_startTime)!.date) : ''; + const endTime = DateCast(doc.$task_endTime) ? toLocalDateTimeString(DateCast(doc.$task_endTime)!.date) : ''; + + const handleGoogleTaskSync = async () => { + const success = await this.syncWithGoogleTaskBidirectional(); + + if (success) { + alert('✅ Task successfully synced!'); + } else { + alert('❌ Task sync failed. Try reloading.'); + } + }; + + return ( + <div className="task-box-blur-wrapper" tabIndex={0} onBlur={this.handleBlur} onFocus={this.handleFocus}> + <div className="task-manager-container"> + <input className="task-manager-title" type="text" placeholder="Task Title" value={taskTitle} onChange={this.updateTitle} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} /> + + <textarea className="task-manager-description" placeholder="What’s your task?" value={taskDesc} onChange={this.updateText} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} /> + + <div className="task-manager-checkboxes"> + <label className="task-manager-allday" style={{ opacity: isCompleted ? 0.7 : 1 }}> + <input type="checkbox" checked={allDay} onChange={this.updateAllDay} disabled={isCompleted} /> + All day + {allDay && ( + <input + type="date" + value={(() => { + const datePart = StrCast(doc.$task_dateRange).split('|')[0]; + if (!datePart) return ''; + const d = new Date(datePart); + return !isNaN(d.getTime()) ? d.toISOString().split('T')[0] : ''; + })()} + onChange={e => { + const newDate = new Date(e.target.value); + if (!isNaN(newDate.getTime())) { + const dateStr = e.target.value; + if (dateStr) { + doc.$task_dateRange = `${dateStr}T00:00:00|${dateStr}T00:00:00`; + } + } + }} + disabled={isCompleted} + style={{ marginLeft: '8px' }} + /> + )} + </label> + + <label className="task-manager-complete"> + <input type="checkbox" checked={isCompleted} onChange={this.toggleComplete} /> + Complete + </label> + </div> + + <div className="task-manager-button-row"> + <button + className="task-manager-google" + onClick={event => { + event.preventDefault(); + handleGoogleTaskSync(); + }}> + {this._syncing ? 'Syncing...' : this.needsSync ? 'Push Updates' : 'Sync Task'} + </button> + + <button + className="task-manager-delete" + onClick={event => { + event.preventDefault(); + this.handleDeleteTask(); + }}> + <svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="12" height="12" viewBox="0 0 24 24" style={{ fill: 'white', marginRight: '6px', verticalAlign: 'middle', marginTop: '-2px' }}> + <path d="M 10 2 L 9 3 L 5 3 C 4.4 3 4 3.4 4 4 C 4 4.6 4.4 5 5 5 L 7 5 L 17 5 L 19 5 C 19.6 5 20 4.6 20 4 C 20 3.4 19.6 3 19 3 L 15 3 L 14 2 L 10 2 z M 5 7 L 5 20 C 5 21.1 5.9 22 7 22 L 17 22 C 18.1 22 19 21.1 19 20 L 19 7 L 5 7 z M 9 9 C 9.6 9 10 9.4 10 10 L 10 19 C 10 19.6 9.6 20 9 20 C 8.4 20 8 19.6 8 19 L 8 10 C 8 9.4 8.4 9 9 9 z M 15 9 C 15.6 9 16 9.4 16 10 L 16 19 C 16 19.6 15.6 20 15 20 C 14.4 20 14 19.6 14 19 L 14 10 C 14 9.4 14.4 9 15 9 z"></path> + </svg> + Delete + </button> + </div> + + {!allDay && ( + <div className="task-manager-times" style={{ opacity: isCompleted ? 0.7 : 1 }}> + <label> + Start: + <input type="datetime-local" value={startTime} onChange={this.updateStart} disabled={isCompleted} /> + </label> + <label> + End: + <input type="datetime-local" value={endTime} onChange={this.updateEnd} disabled={isCompleted} /> + </label> + </div> + )} + </div> + </div> + ); + } +} + +Docs.Prototypes.TemplateMap.set(DocumentType.TASK, { + layout: { view: TaskBox, dataField: 'text' }, + options: { + acl: '', + _height: 35, + _xMargin: 10, + _yMargin: 10, + _layout_autoHeight: true, + _layout_nativeDimEditable: true, + _layout_reflowVertical: true, + _layout_reflowHorizontal: true, + task: '', + defaultDoubleClick: 'ignore', + systemIcon: 'BsCheckSquare', + height_min: 300, + width_min: 300, + }, +}); diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index b5405f0fb..27f419198 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -3,8 +3,8 @@ .mini-viewer { cursor: grab; position: absolute; - right: 10; - top: 10; + right: 10px; + top: 10px; opacity: 0.1; transition: all 0.4s; color: white; @@ -38,7 +38,7 @@ .videoBox-annotationLayer { position: relative; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; // bcz: makes text fuzzy! @@ -81,8 +81,8 @@ // } .videoBox-ui-wrapper { - width: 0; - height: 0; + width: 0px; + height: 0px; position: relative; z-index: 2000; } diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index fa099178c..f994bdbb5 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -30,6 +30,7 @@ import { StyleProp } from '../StyleProp'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; +import { gptImageLabel } from '../../apis/gpt/GPT'; import './VideoBox.scss'; /** @@ -109,6 +110,52 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return this._videoRef; } + autoTag = async () => { + if (this.Document.$tags_chat) return; + try { + if (!this.player) throw new Error('Video element not available.'); + + // 1) Extract a frame at the video's midpoint + const videoDuration = this.player.duration; + const snapshotTime = videoDuration / 2; + + // Seek the video element to the midpoint + await new Promise<void>(resolve => { + const onSeeked = () => { + this.player!.removeEventListener('seeked', onSeeked); + resolve(); + }; + this.player!.addEventListener('seeked', onSeeked); + this.player!.currentTime = snapshotTime; + }); + + // 2) Draw the frame onto a canvas and get a base64 representation + const canvas = document.createElement('canvas'); + canvas.width = this.player.videoWidth; + canvas.height = this.player.videoHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to create canvas context.'); + ctx.drawImage(this.player, 0, 0, canvas.width, canvas.height); + const base64Image = canvas.toDataURL('image/png'); + + // 3) Send the image data to GPT for classification and descriptive tags + const label = await gptImageLabel( + base64Image, + `Classify this video frame as either a PERSON or LANDSCAPE. + Then provide five additional descriptive tags (single words) separated by spaces. + Finally, add one detailed summary phrase using underscores.` + ).then(raw => raw.trim().toUpperCase()); + + // 4) Normalize and store labels in the Document's tags + const aspect = this.player!.videoWidth / (this.player!.videoHeight || 1); + this.Document.$tags_chat = new List<string>([...label.split(/\s+/), `ASPECT_${aspect}`]); + // 5) Turn on tag display in layout + this.Document._layout_showTags = true; + } catch (err) { + console.error('Video autoTag failed:', err); + } + }; + componentDidMount() { this.unmounting = false; this._props.setContentViewBox?.(this); // this tells the DocumentView that this VideoBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the VideoBox when making a link. @@ -338,12 +385,17 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const timecode = Cast(this.layoutDoc._layout_currentTimecode, 'number', null); const marquee = AnchorMenu.Instance.GetAnchor?.(undefined, addAsAnnotation); + const docAnchor = () => + Docs.Create.ConfigDocument({ + title: '#' + timecode, + _timecodeToShow: timecode, + annotationOn: this.Document, + }); if (!addAsAnnotation && marquee) marquee.backgroundColor = 'transparent'; - const anchor = - addAsAnnotation && marquee - ? CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this.annotationKey, timecode || undefined, undefined, marquee, addAsAnnotation) || this.Document - : Docs.Create.ConfigDocument({ title: '#' + timecode, _timecodeToShow: timecode, annotationOn: this.Document }); + const visibleAnchor = () => addAsAnnotation && marquee && (CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this.annotationKey, timecode || undefined, undefined, marquee, addAsAnnotation) || this.Document); + const anchor = visibleAnchor() || docAnchor(); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), temporal: true, pannable: true } }, this.Document); + addAsAnnotation && this.addDocument(anchor); return anchor; }; @@ -376,9 +428,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } return this._stackedTimeline.getView(doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res)); }; // extracts video thumbnails and saves them as field of doc diff --git a/src/client/views/nodes/WebBox.scss b/src/client/views/nodes/WebBox.scss index 77d7716f4..f1c964980 100644 --- a/src/client/views/nodes/WebBox.scss +++ b/src/client/views/nodes/WebBox.scss @@ -3,8 +3,8 @@ .webBox { height: 100%; width: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; position: relative; display: flex; overflow: hidden; @@ -28,8 +28,8 @@ height: 100%; z-index: 1; pointer-events: none; - top: 0; - left: 0; + top: 0px; + left: 0px; overflow: hidden; .webBox-overlayButton { @@ -39,16 +39,16 @@ align-items: center; height: 20px; background: none; - padding: 0; + padding: 0px; position: absolute; pointer-events: all; color: white; - bottom: 0; - right: 0; + bottom: 0px; + right: 0px; .webBox-overlayButton-arrow { - width: 0; - height: 0; + width: 0px; + height: 0px; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 15px solid #121721; @@ -92,7 +92,7 @@ width: calc(100% - 40px); height: 20px; background: #121721; - bottom: 0; + bottom: 0px; display: flex; justify-content: center; align-items: center; @@ -137,7 +137,7 @@ .webBox-annotationLayer { position: absolute; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; // bcz: makes text fuzzy! @@ -156,8 +156,8 @@ .webBox-htmlSpan { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; cursor: text; padding: 15px; height: 100%; @@ -171,8 +171,8 @@ .webBox-cont-interactive { padding: 0vw; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; transform-origin: top left; @@ -181,8 +181,8 @@ width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; body { ::selection { color: white; @@ -203,8 +203,8 @@ height: 100%; position: absolute; transform-origin: top left; - top: 0; - left: 0; + top: 0px; + left: 0px; overflow: auto; .webBox-innerContent { @@ -224,7 +224,7 @@ } .webBox-buttons { - margin-left: 44; + margin-left: 44px; background: lightGray; width: 100%; } @@ -232,8 +232,8 @@ .webBox-annotationToggle { z-index: 901; position: absolute; - top: 2; - left: 2; + top: 2px; + left: 2px; cursor: pointer; box-shadow: black 0.3em 0.3em 1em; border-radius: 5px; diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 1e158f484..881cdae37 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -13,7 +13,7 @@ import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { RefField } from '../../../fields/RefField'; import { listSpec } from '../../../fields/Schema'; -import { Cast, NumCast, StrCast, toList, WebCast } from '../../../fields/Types'; +import { Cast, DocCast, NumCast, StrCast, toList, WebCast } from '../../../fields/Types'; import { ImageField, WebField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction, stringHash } from '../../../Utils'; @@ -104,6 +104,9 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @computed get webField() { return Cast(this.Document[this._props.fieldKey], WebField)?.url; } + @computed get sidebarKey() { + return this.fieldKey + '_sidebar'; + } constructor(props: FieldViewProps) { super(props); @@ -308,18 +311,17 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; @action - getView = (doc: Doc /* , options: FocusViewOptions */) => { - if (Doc.AreProtosEqual(doc, this.Document)) - return new Promise<Opt<DocumentView>>(res => { - res(this.DocumentView?.()); - }); + getView = async (doc: Doc, options: FocusViewOptions) => { + if (Doc.AreProtosEqual(doc, this.Document)) return new Promise<Opt<DocumentView>>(res => res(this.DocumentView?.())); + if (this.Document.layout_fieldKey === 'layout_icon') this.DocumentView?.().iconify(); const webUrl = WebCast(doc.config_data)?.url; if (this._url && webUrl && webUrl.href !== this._url) this.setData(webUrl.href); - if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) this.toggleSidebar(false); - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + + if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { + return SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, () => this.toggleSidebar(false), doc, options); + } + return undefined; }; sidebarAddDocTab = (doc: Doc, where: OpenWhere) => { @@ -393,7 +395,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { .transformPoint(e.clientX, e.clientY - NumCast(this.layoutDoc.layout_scrollTop)); if (!this._marqueeref.current?.isEmpty) this._marqueeref.current?.onEnd(theclick[0], theclick[1]); else { - if (!(e.target as HTMLElement)?.tagName?.includes('INPUT')) this.finishMarquee(theclick[0], theclick[1]); + if (!(e.target as HTMLElement)?.tagName?.includes('INPUT') && !(e.target as HTMLElement)?.tagName?.includes('TEXTAREA')) this.finishMarquee(theclick[0], theclick[1]); this._getAnchor = AnchorMenu.Instance?.GetAnchor; this.marqueeing = undefined; } @@ -454,7 +456,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { iframeDown = (e: PointerEvent) => { this._textAnnotationCreator = undefined; const sel = this._url ? this._iframe?.contentDocument?.getSelection() : window.document.getSelection(); - if (sel?.empty && !(e.target as any).textContent) + if (sel?.empty && !(e.target as HTMLElement).textContent) sel.empty(); // Chrome else if (sel?.removeAllRanges) sel.removeAllRanges(); // Firefox @@ -465,6 +467,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { .transformPoint(e.clientX, e.clientY - NumCast(this.layoutDoc.layout_scrollTop)); MarqueeAnnotator.clearAnnotations(this._savedAnnotations); const target = e.target as HTMLElement; + if ((target as HTMLElement)?.tagName?.includes('INPUT') || (target as HTMLElement)?.tagName?.includes('TEXTAREA')) e.stopPropagation(); const word = target && getWordAtPoint(target, e.clientX, e.clientY); if (!word && !target?.className?.includes('rangeslider') && !target?.onclick && !target?.parentElement?.onclick) { this.marqueeing = theclick; @@ -657,7 +660,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { 'click', undoable( action((e: MouseEvent) => { - let eleHref = (e.target as any)?.outerHTML?.split('"="')[1]?.split('"')[0]; + let eleHref = (e.target as HTMLElement)?.outerHTML?.split('"="')[1]?.split('"')[0]; for (let ele = e.target as HTMLElement | Element | null; ele; ele = ele.parentElement) { if ('href' in ele) { eleHref = (typeof ele.href === 'string' ? ele.href : eleHref) || (ele.parentElement && 'href' in ele.parentElement ? (ele.parentElement.href as string) : eleHref); diff --git a/src/client/views/nodes/audio/AudioWaveform.scss b/src/client/views/nodes/audio/AudioWaveform.scss index 6cbd1759a..c6b0da9c8 100644 --- a/src/client/views/nodes/audio/AudioWaveform.scss +++ b/src/client/views/nodes/audio/AudioWaveform.scss @@ -1,17 +1,17 @@ -.audioWaveform { +.audioWaveform { position: relative; width: 100%; height: 200%; overflow: hidden; z-index: -1000; - bottom: 0; + bottom: 0px; pointer-events: none; div { height: 100% !important; - width: 100% !important; + width: 100% !important; } - canvas { + canvas { height: 100% !important; - width: 100% !important; + width: 100% !important; } } diff --git a/src/client/views/nodes/calendarBox/CalendarBox.scss b/src/client/views/nodes/calendarBox/CalendarBox.scss index f8ac4b2d1..89dc294a5 100644 --- a/src/client/views/nodes/calendarBox/CalendarBox.scss +++ b/src/client/views/nodes/calendarBox/CalendarBox.scss @@ -1,9 +1,12 @@ +.calendarBox-interactive, .calendarBox { display: flex; width: 100%; height: 100%; transform-origin: top left; - .calendarBox-wrapper { + overflow: auto; + > div { + pointer-events: none; width: 100%; height: 100%; .fc-timegrid-body { @@ -23,3 +26,36 @@ } } } + +// AARAV ADD + +/* Existing styles */ +.fc-event.mother { + font-weight: 500; + border-radius: 4px; + padding: 2px 4px; + border-width: 2px; + } + + /* New styles for completed tasks */ + .fc-event.completed-task { + opacity: 1; + filter: grayscale(70%) brightness(90%); + text-decoration: line-through; + color: #ffffff; + } + +.calendarBox-interactive { + > div { + pointer-events: unset; + } +} + +.custom-drag-mirror { + transition: none !important; + transform: none !important; +} + +.fc-event-dragging { + opacity: 0 !important; +} diff --git a/src/client/views/nodes/calendarBox/CalendarBox.tsx b/src/client/views/nodes/calendarBox/CalendarBox.tsx index 2b20a666d..a2fa83b5a 100644 --- a/src/client/views/nodes/calendarBox/CalendarBox.tsx +++ b/src/client/views/nodes/calendarBox/CalendarBox.tsx @@ -1,15 +1,17 @@ -import { Calendar, EventClickArg, EventDropArg, EventSourceInput } from '@fullcalendar/core'; +import { Calendar, DateSelectArg, EventClickArg, EventDropArg, EventMountArg, EventSourceInput } from '@fullcalendar/core'; +import { EventResizeDoneArg } from '@fullcalendar/interaction'; import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin from '@fullcalendar/interaction'; import multiMonthPlugin from '@fullcalendar/multimonth'; import timeGrid from '@fullcalendar/timegrid'; -import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; +import FullCalendar from '@fullcalendar/react'; +import { IReactionDisposer, action, computed, makeObservable, observable, reaction, untracked } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { dateRangeStrToDates } from '../../../../ClientUtils'; import { Doc } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; -import { BoolCast, NumCast, StrCast } from '../../../../fields/Types'; +import { BoolCast, StrCast } from '../../../../fields/Types'; import { DocServer } from '../../../DocServer'; import { DragManager } from '../../../util/DragManager'; import { CollectionSubView, SubCollectionViewProps } from '../../collections/CollectionSubView'; @@ -17,26 +19,35 @@ import { ContextMenu } from '../../ContextMenu'; import { DocumentView } from '../DocumentView'; import { OpenWhere } from '../OpenWhere'; import './CalendarBox.scss'; +import { DateField } from '../../../../fields/DateField'; +import { undoable } from '../../../util/UndoManager'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { truncate } from 'fs/promises'; type CalendarView = 'multiMonth' | 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; @observer export class CalendarBox extends CollectionSubView() { - _calendarRef: HTMLDivElement | null = null; + _calendarRef: FullCalendar | null = null; _calendar: Calendar | undefined; _observer: ResizeObserver | undefined; _eventsDisposer: IReactionDisposer | undefined; _selectDisposer: IReactionDisposer | undefined; + _isMultiMonth: boolean | undefined; + + @observable _multiMonth = 0; constructor(props: SubCollectionViewProps) { super(props); makeObservable(this); } - @observable _multiMonth = 0; - isMultiMonth: boolean | undefined; + @computed get calTypeFieldKey() { + return this.fieldKey + '_calendarType'; + } componentDidMount(): void { + this.Document.$calendar = ''; // needed only to make the keyvalue view look nice. this._props.setContentViewBox?.(this); this._eventsDisposer = reaction( () => ({ events: this.calendarEvents }), @@ -52,7 +63,7 @@ export class CalendarBox extends CollectionSubView() { type: 'CHANGE_DATE', dateMarker: state.dateEnv.createMarker(initialDate.start), }); - setTimeout(() => (initialDate.start.toISOString() !== initialDate.end.toISOString() ? this._calendar?.select(initialDate.start, initialDate.end) : this._calendar?.select(initialDate.start))); + setTimeout(() => initialDate.start.toISOString() !== initialDate.end.toISOString() && this._calendar?.select(initialDate.start, initialDate.end)); }, { fireImmediately: true } ); @@ -64,7 +75,20 @@ export class CalendarBox extends CollectionSubView() { @computed get calendarEvents(): EventSourceInput | undefined { return this.childDocs.map(doc => { - const { start, end } = dateRangeStrToDates(StrCast(doc.date_range)); + // const { start, end } = dateRangeStrToDates(StrCast(doc.$task_dateRange)); + const isCompleted = BoolCast(doc.$task_completed); + + const rangeStr = StrCast(doc.$task_dateRange); + const [startStr, endStr] = rangeStr.split('|'); + let start: string | Date, end: string | Date; + + if (BoolCast(doc.$task_allDay)) { + start = startStr; + end = endStr; + } else { + ({ start, end } = dateRangeStrToDates(rangeStr)); + } + return { title: StrCast(doc.title), start, @@ -72,8 +96,8 @@ export class CalendarBox extends CollectionSubView() { groupId: doc[Id], startEditable: true, endEditable: true, - allDay: BoolCast(doc.allDay), - classNames: ['mother'], // will determine the style + allDay: BoolCast(doc.$task_allDay), + classNames: ['mother', isCompleted ? 'completed-task' : ''], // will determine the style editable: true, // subject to change in the future backgroundColor: this.eventToColor(doc), borderColor: this.eventToColor(doc), @@ -86,16 +110,16 @@ export class CalendarBox extends CollectionSubView() { } @computed get dateRangeStrDates() { - return dateRangeStrToDates(StrCast(this.Document.date_range)); + return dateRangeStrToDates(StrCast(this.Document._calendar_dateRange)); } get dateSelect() { - return dateRangeStrToDates(StrCast(this.Document.date)); + return dateRangeStrToDates(StrCast(this.Document._calendar_date)); } // Choose a calendar view based on the date range @computed get calendarViewType(): CalendarView { - if (this.dataDoc[this.fieldKey + '_calendarType']) return StrCast(this.dataDoc[this.fieldKey + '_calendarType']) as CalendarView; - if (this.isMultiMonth) return 'multiMonth'; + if (this.dataDoc[this.calTypeFieldKey]) return StrCast(this.dataDoc[this.calTypeFieldKey]) as CalendarView; + if (this._isMultiMonth) return 'multiMonth'; const { start, end } = this.dateRangeStrDates; if (start.getFullYear() !== end.getFullYear() || start.getMonth() !== end.getMonth()) return 'multiMonth'; if (Math.abs(start.getDay() - end.getDay()) > 7) return 'dayGridMonth'; @@ -104,7 +128,9 @@ export class CalendarBox extends CollectionSubView() { // TODO: Return a different color based on the event type eventToColor = (event: Doc): string => { - return 'red' + event; + return StrCast(event.type) === DocumentType.TASK + ? '#20B2AA' // teal for tasks + : 'red'; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -112,7 +138,7 @@ export class CalendarBox extends CollectionSubView() { if (!super.onInternalDrop(e, de)) return false; de.complete.docDragData?.droppedDocuments.forEach(doc => { const today = new Date().toISOString(); - if (!doc.date_range) doc.$date_range = `${today}|${today}`; + if (!doc.$task_dateRange) doc.$task_dateRange = `${today}|${today}`; }); return true; }; @@ -122,10 +148,31 @@ export class CalendarBox extends CollectionSubView() { return false; }; - handleEventDrop = (arg: EventDropArg) => { + handleEventDrop = undoable((arg: EventDropArg | EventResizeDoneArg) => { const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); - doc && arg.event.start && (doc.date_range = arg.event.start?.toString() + '|' + (arg.event.end ?? arg.event.start).toString()); - }; + // doc && arg.event.start && (doc.$task_dateRange = arg.event.start?.toString() + '|' + (arg.event.end ?? arg.event.start).toString()); + if (!doc || !arg.event.start) return; + + // get the new start and end dates + const startDate = new Date(arg.event.start); + const endDate = new Date(arg.event.end ?? arg.event.start); + + // update date range, time range, and all day status + doc.$task_dateRange = `${startDate.toISOString()}|${endDate.toISOString()}`; + + const allDayStatus = arg.event.allDay ?? false; + if (doc.$task_allDay !== allDayStatus) { + doc.$task_allDay = allDayStatus; + } + + if (doc.$task_allDay) { + delete doc.$task_startTime; + delete doc.$task_endTime; + } else { + doc.$task_startTime = new DateField(startDate); + doc.$task_endTime = new DateField(endDate); + } + }, 'change event date'); handleEventClick = (arg: EventClickArg) => { const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); @@ -144,90 +191,199 @@ export class CalendarBox extends CollectionSubView() { }; // https://fullcalendar.io - renderCalendar = () => { - const cal = !this._calendarRef - ? null - : (this._calendar = new Calendar(this._calendarRef, { - plugins: [multiMonthPlugin, dayGridPlugin, timeGrid, interactionPlugin], - headerToolbar: { - left: 'prev,next today', - center: 'title', - right: 'multiMonth dayGridMonth timeGridWeek timeGridDay', - }, - selectable: true, - initialView: this.calendarViewType === 'multiMonth' ? undefined : this.calendarViewType, - initialDate: this.dateSelect.start, - navLinks: true, - editable: false, - displayEventTime: false, - displayEventEnd: false, - select: info => { - const start = dateRangeStrToDates(info.startStr).start.toISOString(); - const end = dateRangeStrToDates(info.endStr).start.toISOString(); - this.dataDoc.date = start + '|' + end; - }, - aspectRatio: NumCast(this.Document.width) / NumCast(this.Document.height), - events: this.calendarEvents, - eventClick: this.handleEventClick, - eventDrop: this.handleEventDrop, - eventDidMount: arg => { - arg.el.addEventListener('pointerdown', ev => { - ev.button && ev.stopPropagation(); - }); - if (navigator.userAgent.includes('Macintosh')) { - arg.el.addEventListener('pointerup', ev => { - ev.button && ev.stopPropagation(); - ev.button && this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); - }); - } - arg.el.addEventListener('contextmenu', ev => { - if (!navigator.userAgent.includes('Macintosh')) { - this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); + @computed get renderCalendar() { + const availableWidth = this._props.PanelWidth() / (this._props.DocumentView?.().UIBtnScaling ?? 1); + const btn = (text: string, view: string | (() => void), hint: string) => ({ text, hint, click: typeof view === 'string' ? () => this._calendarRef?.getApi().changeView(view) : view }); + return ( + <FullCalendar + ref={(r: unknown) => (this._calendarRef = r as FullCalendar)} + customButtons={{ + nowBtn: btn('Now', () => this._calendarRef?.getApi().gotoDate(new Date()), 'Go to Today'), + multiBtn: btn('M+', 'multiMonth', 'Multiple Month View'), + monthBtn: btn('M', 'dayGridMonth', 'Month View'), + weekBtn: btn('W', 'timeGridWeek', 'Week View'), + dayBtn: btn('D', 'timeGridDay', 'Day View'), + }} + headerToolbar={ + availableWidth > 450 + ? { + left: 'prev,next nowBtn', + center: 'title', + right: 'multiBtn monthBtn weekBtn dayBtn', } - ev.stopPropagation(); - ev.preventDefault(); - }); - }, - })); - cal?.render(); - setTimeout(() => cal?.view.calendar.select(this.dateSelect.start, this.dateSelect.end)); - }; + : availableWidth > 300 + ? { + left: 'prev,next', + center: 'title', + right: '', + } + : { + left: '', + center: 'title', + right: '', + } + } + selectable={true} + initialView={this.calendarViewType === 'multiMonth' ? undefined : this.calendarViewType} + views={{ + multiMonth: { + type: 'multiMonth', + duration: { months: 12 }, + }, + }} + initialDate={untracked(() => this.dateSelect.start)} + navLinks={true} + editable={true} + // expandRows={true} + // handleWindowResize={true} + displayEventTime={false} + displayEventEnd={false} + plugins={[multiMonthPlugin, dayGridPlugin, timeGrid, interactionPlugin]} + aspectRatio={this._props.PanelWidth() / this._props.PanelHeight()} + weekends={true} + events={this.calendarEvents} + eventClick={this.handleEventClick} + eventDrop={this.handleEventDrop} + eventResize={this.handleEventDrop} + unselectAuto={false} + // unselect={() => {}} + select={(info: DateSelectArg) => { + const start = dateRangeStrToDates(info.startStr).start.toISOString(); + const end = info.allDay ? start : dateRangeStrToDates(info.endStr).start.toISOString(); + this.Document._calendar_date = start + '|' + end; + }} + // eventContent={() => { + // return null; + // }} + eventDidMount={(arg: EventMountArg) => { + const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); + if (!doc) return; + + if (doc.type === DocumentType.TASK) { + const checkButton = document.createElement('button'); + checkButton.innerText = doc.$task_completed ? '✅' : '⬜'; + checkButton.style.position = 'absolute'; + checkButton.style.right = '5px'; + checkButton.style.top = '50%'; + checkButton.style.transform = 'translateY(-50%)'; + checkButton.style.background = 'transparent'; + checkButton.style.border = 'none'; + checkButton.style.cursor = 'pointer'; + checkButton.style.fontSize = '18px'; + checkButton.style.zIndex = '1000'; + checkButton.style.padding = '0'; + checkButton.style.margin = '0'; + + checkButton.onclick = ev => { + ev.stopPropagation(); + doc.$task_completed = !doc.$task_completed; + this._calendar?.refetchEvents(); + }; + arg.el.style.position = 'relative'; + arg.el.appendChild(checkButton); + } + arg.el.addEventListener('pointerdown', ev => ev.button && ev.stopPropagation()); + if (navigator.userAgent.includes('Macintosh')) { + arg.el.addEventListener('pointerup', ev => { + ev.button && ev.stopPropagation(); + ev.button && this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); + }); + } + arg.el.addEventListener('contextmenu', ev => { + if (!navigator.userAgent.includes('Macintosh')) { + this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); + } + ev.stopPropagation(); + ev.preventDefault(); + }); + }} + // for dragging and dropping (mirror) + + eventDragStart={arg => { + const mirror = arg.el.cloneNode(true) as HTMLElement; + const rect = arg.el.getBoundingClientRect(); + + mirror.style.position = 'fixed'; + mirror.style.pointerEvents = 'none'; + mirror.style.opacity = '0.8'; + mirror.style.zIndex = '10000'; + mirror.classList.add('custom-drag-mirror'); + mirror.style.width = `${rect.width}px`; + mirror.style.height = `${rect.height}px`; + + document.body.appendChild(mirror); + + const moveListener = (ev: MouseEvent) => { + mirror.style.left = `${ev.clientX}px`; + mirror.style.top = `${ev.clientY}px`; + }; + + window.addEventListener('mousemove', moveListener); + + // hide the actual box + arg.el.style.visibility = 'hidden'; + arg.el.style.opacity = '0'; + + (arg.el as any)._mirrorElement = mirror; + (arg.el as any)._moveListener = moveListener; + }} + eventDragStop={arg => { + const el = arg.el as any; + const mirror = el._mirrorElement; + const moveListener = el._moveListener; + + // show the actual box + el.style.visibility = 'visible'; + el.style.opacity = '1'; + + if (mirror) document.body.removeChild(mirror); + if (moveListener) window.removeEventListener('mousemove', moveListener); + }} + /> + ); + } + + setRef = (r: HTMLDivElement | null) => { + this.createDashEventsTarget(r); + this.fixWheelEvents(r, this._props.isContentActive); + }; render() { + const scale = this._props.ScreenToLocalTransform().Scale; + const scaledWidth = this._props.PanelWidth(); + const scaledHeight = this._props.PanelHeight(); + return ( <div key={this.calendarViewType} - className="calendarBox" + className={`calendarBox${this._props.isContentActive() ? '-interactive' : ''}`} + style={{ + width: scaledWidth, + height: scaledHeight, + overflow: 'hidden', + position: 'relative', + }} + ref={this.setRef} onPointerDown={e => { setTimeout( action(() => { const cname = (e.nativeEvent.target as HTMLButtonElement)?.className ?? ''; - if (cname.includes('multiMonth')) this.dataDoc[this.fieldKey + '_calendarType'] = 'multiMonth'; - if (cname.includes('dayGridMonth')) this.dataDoc[this.fieldKey + '_calendarType'] = 'dayGridMonth'; - if (cname.includes('timeGridWeek')) this.dataDoc[this.fieldKey + '_calendarType'] = 'timeGridWeek'; - if (cname.includes('timeGridDay')) this.dataDoc[this.fieldKey + '_calendarType'] = 'timeGridDay'; + if (cname.includes('multiMonth')) this.dataDoc[this.calTypeFieldKey] = 'multiMonth'; + if (cname.includes('dayGridMonth')) this.dataDoc[this.calTypeFieldKey] = 'dayGridMonth'; + if (cname.includes('timeGridWeek')) this.dataDoc[this.calTypeFieldKey] = 'timeGridWeek'; + if (cname.includes('timeGridDay')) this.dataDoc[this.calTypeFieldKey] = 'timeGridDay'; }) ); - }} - style={{ - width: this._props.PanelWidth() / this._props.ScreenToLocalTransform().Scale, - height: this._props.PanelHeight() / this._props.ScreenToLocalTransform().Scale, - transform: `scale(${this._props.ScreenToLocalTransform().Scale})`, - }} - ref={r => { - this.createDashEventsTarget(r); - this.fixWheelEvents(r, this._props.isContentActive); - - if (r) { - this._observer?.disconnect(); - (this._observer = new ResizeObserver(() => { - this._calendar?.setOption('aspectRatio', NumCast(this.Document.width) / NumCast(this.Document.height)); - this._calendar?.updateSize(); - })).observe(r); - this.renderCalendar(); - } }}> - <div className="calendarBox-wrapper" ref={r => (this._calendarRef = r)} /> + <div + style={{ + transform: `scale(${scale})`, + transformOrigin: 'top left', + width: scaledWidth / scale, + height: scaledHeight / scale, + }}> + {this.renderCalendar} + </div> </div> ); } diff --git a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss index 8e00cbdb7..18a179c67 100644 --- a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss +++ b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss @@ -50,7 +50,7 @@ $font-size-xlarge: 18px; position: relative; h2 { - margin: 0; + margin: 0px; font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; @@ -103,7 +103,7 @@ $font-size-xlarge: 18px; margin-bottom: 4px; &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } &:hover { @@ -191,8 +191,8 @@ $font-size-xlarge: 18px; content: ''; position: absolute; top: -5px; - left: 0; - right: 0; + left: 0px; + right: 0px; height: 5px; background: linear-gradient(to top, rgba(0, 0, 0, 0.06), transparent); pointer-events: none; @@ -457,11 +457,11 @@ $font-size-xlarge: 18px; margin: 8px 0; &:first-child { - margin-top: 0; + margin-top: 0px; } &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } } @@ -521,8 +521,8 @@ $font-size-xlarge: 18px; border-bottom: 1px dashed #e5e7eb; &:last-child { - margin-bottom: 0; - padding-bottom: 0; + margin-bottom: 0px; + padding-bottom: 0px; border-bottom: none; } @@ -581,7 +581,7 @@ $font-size-xlarge: 18px; .message-content { background-color: inherit; - padding: 0; + padding: 0px; border-radius: 8px; font-size: 14px; line-height: 1.6; @@ -708,10 +708,10 @@ $font-size-xlarge: 18px; .uploading-overlay { position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; background-color: rgba(255, 255, 255, 0.92); display: flex; justify-content: center; @@ -807,7 +807,7 @@ $font-size-xlarge: 18px; @media (max-width: 768px) { .chat-box { - border-radius: 0; + border-radius: 0px; } .message { @@ -954,10 +954,10 @@ $font-size-xlarge: 18px; /* Tool Reload Modal Styles */ .tool-reload-modal-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; @@ -969,7 +969,7 @@ $font-size-xlarge: 18px; .tool-reload-modal { background: white; border-radius: 12px; - padding: 0; + padding: 0px; min-width: 400px; max-width: 500px; box-shadow: @@ -995,7 +995,7 @@ $font-size-xlarge: 18px; border-bottom: 1px solid #e2e8f0; h3 { - margin: 0; + margin: 0px; font-size: 18px; font-weight: 600; color: #1a202c; @@ -1019,7 +1019,7 @@ $font-size-xlarge: 18px; color: #4a5568; &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } strong { diff --git a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx index df6c5627c..6e6ef6212 100644 --- a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx +++ b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx @@ -15,7 +15,7 @@ import * as React from 'react'; import { v4 as uuidv4 } from 'uuid'; import { ClientUtils, OmitKeys } from '../../../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../../../fields/Doc'; -import { DocData, DocLayout, DocViews } from '../../../../../fields/DocSymbols'; +import { DocData, DocViews } from '../../../../../fields/DocSymbols'; import { Id } from '../../../../../fields/FieldSymbols'; import { RichTextField } from '../../../../../fields/RichTextField'; import { ScriptField } from '../../../../../fields/ScriptField'; @@ -44,7 +44,6 @@ import './ChatBox.scss'; import MessageComponentBox from './MessageComponent'; import { OpenWhere } from '../../OpenWhere'; import { Upload } from '../../../../../server/SharedMediaTypes'; -import { DocumentMetadataTool } from '../tools/DocumentMetadataTool'; import { AgentDocumentManager } from '../utils/AgentDocumentManager'; dotenv.config(); @@ -497,7 +496,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { case supportedDocTypes.image: return Docs.Create.ImageDocument(data as string, options); case supportedDocTypes.equation: return Docs.Create.EquationDocument(data as string, options); case supportedDocTypes.notetaking: return Docs.Create.NoteTakingDocument([], options); - case supportedDocTypes.web: + case supportedDocTypes.web: { // Create web document with enhanced safety options const webOptions = { ...options, @@ -506,10 +505,11 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // If iframe_sandbox was passed from AgentDocumentManager, add it to the options if ('_iframe_sandbox' in options) { - (webOptions as any)._iframe_sandbox = options._iframe_sandbox; + webOptions._iframe_sandbox = options._iframe_sandbox; } return Docs.Create.WebDocument(data as string, webOptions); + } case supportedDocTypes.dataviz: return Docs.Create.DataVizDocument('/users/rz/Downloads/addresses.csv', options); case supportedDocTypes.pdf: return Docs.Create.PdfDocument(data as string, options); case supportedDocTypes.video: return Docs.Create.VideoDocument(data as string, options); @@ -640,7 +640,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { handleCitationClick = async (citation: Citation) => { try { // Extract values from MobX proxy object if needed - const chunkId = typeof citation.chunk_id === 'object' ? (citation.chunk_id as any).toString() : citation.chunk_id; + const chunkId = typeof citation.chunk_id === 'object' ? (citation.chunk_id as unknown as object).toString() : citation.chunk_id; // For debugging console.log('Citation clicked:', { @@ -682,7 +682,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } this.handleOtherChunkTypes(foundChunk, citation, doc, dataDoc); // Show the chunk text in citation popup - let chunkText = citation.direct_text || 'Text content not available'; + const chunkText = citation.direct_text || 'Text content not available'; this.showCitationPopup(chunkText); // Also navigate to the document @@ -841,14 +841,16 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } break; case CHUNK_TYPE.TEXT: - this._citationPopup = { text: citation.direct_text ?? 'No text available', visible: true }; - this.startCitationPopupTimer(); + { + this._citationPopup = { text: citation.direct_text ?? 'No text available', visible: true }; + this.startCitationPopupTimer(); - // Check if the document is a PDF (has a PDF viewer component) - const isPDF = PDFCast(dataDoc!.data) !== null || dataDoc!.type === DocumentType.PDF; + // Check if the document is a PDF (has a PDF viewer component) + const isPDF = PDFCast(dataDoc!.data) !== null || dataDoc!.type === DocumentType.PDF; - // First ensure document is fully visible before trying to access its views - this.ensureDocumentIsVisible(dataDoc!, isPDF, citation, foundChunk, doc); + // First ensure document is fully visible before trying to access its views + this.ensureDocumentIsVisible(dataDoc!, isPDF, citation, foundChunk, doc); + } break; case CHUNK_TYPE.CSV: case CHUNK_TYPE.URL: @@ -1163,6 +1165,9 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this._inputValue = question; }; + _dictation: DictationButton | null = null; + setInputRef = (r: HTMLInputElement) => (this._textInputRef = r); + setDictationRef = (r: DictationButton) => (this._dictation = r); /** * Handles tool creation notification and shows the reload modal * @param toolName The name of the tool that was created @@ -1213,8 +1218,6 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }, 100); }; - _dictation: DictationButton | null = null; - /** * Toggles the font size modal visibility */ @@ -1443,9 +1446,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { <form onSubmit={this.askGPT} className="chat-input"> <div className="input-container"> <input - ref={r => { - this._textInputRef = r; - }} + ref={this.setInputRef} type="text" name="messageInput" autoComplete="off" @@ -1465,13 +1466,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { </svg> )} </button> - <DictationButton - ref={r => { - this._dictation = r; - }} - setInput={this.setChatInput} - inputRef={this._textInputRef} - /> + <DictationButton ref={this.setDictationRef} setInput={this.setChatInput} inputRef={this._textInputRef} /> </form> {/* Popup for citation */} {this._citationPopup.visible && ( @@ -1501,7 +1496,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { The tool <strong>{this._toolReloadModal.toolName}</strong> has been created and saved successfully. </p> <p>To make the tool available for future use, the page needs to be reloaded to rebuild the application bundle.</p> - <p>Click "Reload Page" to complete the tool installation.</p> + <p>Click "Reload Page" to complete the tool installation.</p> </div> <div className="tool-reload-modal-actions"> <button className="reload-button primary" onClick={this.handleReloadConfirmation}> diff --git a/src/client/views/nodes/formattedText/DailyJournal.tsx b/src/client/views/nodes/formattedText/DailyJournal.tsx index 871c556e6..564609494 100644 --- a/src/client/views/nodes/formattedText/DailyJournal.tsx +++ b/src/client/views/nodes/formattedText/DailyJournal.tsx @@ -9,14 +9,22 @@ import { gptAPICall, GPTCallType } from '../../../apis/gpt/GPT'; import { RichTextField } from '../../../../fields/RichTextField'; import { Plugin } from 'prosemirror-state'; import { RTFCast } from '../../../../fields/Types'; +import { Mark } from 'prosemirror-model'; +import { observer } from 'mobx-react'; export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable journalDate: string; - @observable typingTimeout: NodeJS.Timeout | null = null; // Track typing delay - @observable lastUserText: string = ''; // Store last user-entered text + @observable typingTimeout: NodeJS.Timeout | null = null; // track typing delay + @observable lastUserText: string = ''; // store last user-entered text + @observable isLoadingPrompts: boolean = false; // track if prompts are loading + @observable showPromptMenu = false; + @observable inlinePromptsEnabled = true; + @observable askPromptsEnabled = true; + _ref = React.createRef<FormattedTextBox>(); // reference to the formatted textbox predictiveTextRange: { from: number; to: number } | null = null; // where predictive text starts and ends private predictiveText: string | null = ' ... why?'; + private prePredictiveMarks: Mark[] = []; public static LayoutString(fieldStr: string) { return FieldView.LayoutString(DailyJournal, fieldStr); @@ -40,7 +48,7 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() month: 'long', day: 'numeric', }); - console.log('getFormattedDate():', date); + // console.log('getFormattedDate():', date); return date; } @@ -49,15 +57,15 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() */ @action setDailyTitle() { - console.log('setDailyTitle() called...'); - console.log('Current title before update:', this.dataDoc.title); + // console.log('setDailyTitle() called...'); + // console.log('Current title before update:', this.dataDoc.title); if (!this.dataDoc.title || this.dataDoc.title !== this.journalDate) { - console.log('Updating title to:', this.journalDate); + // console.log('Updating title to:', this.journalDate); this.dataDoc.title = this.journalDate; } - console.log('New title after update:', this.dataDoc.title); + // console.log('New title after update:', this.dataDoc.title); } /** @@ -68,7 +76,7 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() const placeholderText = 'Start writing here...'; const dateText = `${this.journalDate}\n`; - console.log('Checking if dataDoc has text field...'); + // console.log('Checking if dataDoc has text field...'); this.dataDoc[this.fieldKey] = RichTextField.textToRtfFormat( [ @@ -80,7 +88,63 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() placeholderText.length ); - console.log('Current text field:', this.dataDoc[this.fieldKey]); + // console.log('Current text field:', this.dataDoc[this.fieldKey]); + } + + /** + * Method to show/hide the prompts menu + */ + @action.bound togglePromptMenu() { + this.showPromptMenu = !this.showPromptMenu; + } + + /** + * Method to toggle on/off inline predictive prompts + */ + @action.bound toggleInlinePrompts() { + this.inlinePromptsEnabled = !this.inlinePromptsEnabled; + } + + /** + * Method to toggle on/off inline /ask prompts + */ + @action.bound toggleAskPrompts() { + this.askPromptsEnabled = !this.askPromptsEnabled; + } + + /** + * Method to handle click on document (to close prompt menu) + * @param e - a click on the document + */ + @action.bound + handleDocumentClick(e: MouseEvent) { + const menu = document.getElementById('prompts-menu'); + const button = document.getElementById('prompts-button'); + if (this.showPromptMenu && menu && !menu.contains(e.target as Node) && button && !button.contains(e.target as Node)) { + this.showPromptMenu = false; + } + } + + /** + * Method to set initial date of document in the calendar view + */ + + @action setInitialDateRange() { + if (!this.dataDoc.$task_dateRange && this.journalDate) { + const parsedDate = new Date(this.journalDate); + if (!isNaN(parsedDate.getTime())) { + const localStart = new Date(parsedDate.getFullYear(), parsedDate.getMonth(), parsedDate.getDate()); + const localEnd = new Date(localStart); // same day + + this.dataDoc.$task_dateRange = `${localStart.toISOString()}|${localEnd.toISOString()}`; + this.dataDoc.$task_allDay = true; + this.dataDoc.$task = ''; // needed only to make the keyvalue view look good. + + // console.log('Set task_dateRange and task_allDay on journal (from local date):', this.dataDoc.$task_dateRange); + } else { + // console.log('Could not parse journalDate:', this.journalDate); + } + } } /** @@ -94,8 +158,26 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() if (this.typingTimeout) clearTimeout(this.typingTimeout); - this.typingTimeout = setTimeout(() => { + const { state } = editorView; + const cursorPos = state.selection.from; + + // characters before cursor + const triggerText = state.doc.textBetween(Math.max(0, cursorPos - 4), cursorPos); + + if (triggerText === '/ask' && this.askPromptsEnabled) { + // remove /ask text + const tr = state.tr.delete(cursorPos - 4, cursorPos); + editorView.dispatch(tr); + + // insert predicted question this.insertPredictiveQuestion(); + return; + } + + this.typingTimeout = setTimeout(() => { + if (this.inlinePromptsEnabled) { + this.insertPredictiveQuestion(); + } }, 3500); }; @@ -129,28 +211,38 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() // Only insert if we're at end of node, or there's a newline node after if (!isAtEndOfParent && !hasNewlineAfter) return; - const fontSizeMark = schema.marks.pFontSize.create({ fontSize: '14px' }); + // Save current marks at cursor + const currentMarks = state.storedMarks || resolvedPos.marks(); + this.prePredictiveMarks = [...currentMarks]; + + // color and italics are preset for predictive question, font and size are adaptive const fontColorMark = schema.marks.pFontColor.create({ fontColor: 'lightgray' }); const fontItalicsMark = schema.marks.em.create(); + const fontSizeMark = this.prePredictiveMarks.find(m => m.type.name === 'pFontSize'); + const fontFamilyMark = this.prePredictiveMarks.find(m => m.type.name === 'pFontFamily'); // if applicable - this.predictiveText = ' ...'; // placeholder for now + this.predictiveText = ' ...'; // placeholder const fullTextUpToCursor = state.doc.textBetween(0, state.selection.to, '\n', '\n'); - const gptPrompt = `Given the following incomplete journal entry, generate a single 2-5 word question that continues the user's thought:\n\n"${fullTextUpToCursor}"`; + const gptPrompt = `Given the following incomplete journal entry, generate a single 2-5 word reflective question that continues the user's thought:\n\n"${fullTextUpToCursor}"`; const res = await gptAPICall(gptPrompt, GPTCallType.COMPLETION); if (!res) return; // styled text node const text = ` ... ${res.trim()}`; - const predictedText = schema.text(text, [fontSizeMark, fontColorMark, fontItalicsMark]); + const predictedText = schema.text(text, [fontColorMark, fontItalicsMark, ...(fontSizeMark ? [fontSizeMark] : []), ...(fontFamilyMark ? [fontFamilyMark] : [])]); // Insert styled text at cursor position - const transaction = state.tr.insert(insertPos, predictedText).setStoredMarks([state.schema.marks.pFontColor.create({ fontColor: 'gray' })]); // should probably instead inquire marks before predictive prompt + const transaction = state.tr.insert(insertPos, predictedText).setStoredMarks(this.prePredictiveMarks); dispatch(transaction); this.predictiveText = text; }; + /** + * Method to remove the predictive question upon type/click + * @returns - once predictive text is found, or all text has been checked + */ createPredictiveCleanupPlugin = () => { return new Plugin({ view: () => { @@ -168,15 +260,20 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() if (node.isText && node.text === textToRemove) { const tr = state.tr.delete(pos, pos + node.nodeSize); - // Set the desired default marks for future input + // default marks for input const fontSizeMark = state.schema.marks.pFontSize.create({ fontSize: '14px' }); const fontColorMark = state.schema.marks.pFontColor.create({ fontColor: 'gray' }); tr.setStoredMarks([]); - tr.setStoredMarks([fontSizeMark, fontColorMark]); + if (this.prePredictiveMarks.length > 0) { + tr.setStoredMarks(this.prePredictiveMarks); + } else { + tr.setStoredMarks([fontSizeMark, fontColorMark]); + } dispatch(tr); this.predictiveText = null; + this.prePredictiveMarks = []; return false; } return true; @@ -194,8 +291,9 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() }; componentDidMount(): void { - console.log('componentDidMount() triggered...'); - console.log('Text: ' + RTFCast(this.Document.text)?.Text); + // console.log('componentDidMount() triggered...'); + document.addEventListener('mousedown', this.handleDocumentClick); + // console.log('Text: ' + RTFCast(this.Document.text)?.Text); const editorView = this._ref.current?.EditorView; if (editorView) { @@ -214,15 +312,17 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() const isDefaultTitle = isTitleString && currentTitle.includes('Untitled DailyJournal'); if (isTextEmpty && isDefaultTitle) { - console.log('Journal title and text are default. Initializing...'); + // console.log('Journal title and text are default. Initializing...'); this.setDailyTitle(); this.setDailyText(); + this.setInitialDateRange(); } else { - console.log('Journal already has content. Skipping initialization.'); + // console.log('Journal already has content. Skipping initialization.'); } } componentWillUnmount(): void { + document.removeEventListener('mousedown', this.handleDocumentClick); const editorView = this._ref.current?.EditorView; if (editorView) { editorView.dom.removeEventListener('input', this.onTextInput); @@ -230,10 +330,20 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() if (this.typingTimeout) clearTimeout(this.typingTimeout); } + /** + * Method to generate pormpts via GPT + * @returns - if failed + */ @action handleGeneratePrompts = async () => { + if (this.isLoadingPrompts) { + return; + } + + this.isLoadingPrompts = true; + const rawText = RTFCast(this.Document.text)?.Text ?? ''; - console.log('Extracted Journal Text:', rawText); - console.log('Before Update:', this.Document.text, 'Type:', typeof this.Document.text); + // console.log('Extracted Journal Text:', rawText); + // console.log('Before Update:', this.Document.text, 'Type:', typeof this.Document.text); if (!rawText.trim()) { alert('Journal is empty! Write something first.'); @@ -273,9 +383,15 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() } } catch (err) { console.error('Error calling GPT:', err); + } finally { + this.isLoadingPrompts = false; } }; + /** + * Method to render the styled DailyJournal + * @returns - the HTML component for the journal + */ render() { return ( <div @@ -296,6 +412,7 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() }}> {/* GPT Button */} <button + id="prompts-button" style={{ position: 'absolute', bottom: '5px', @@ -308,9 +425,88 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() cursor: 'pointer', zIndex: 10, }} - onClick={this.handleGeneratePrompts}> + onClick={this.togglePromptMenu}> Prompts </button> + {this.showPromptMenu && ( + <div + id="prompts-menu" + style={{ + position: 'absolute', + bottom: '45px', + right: '5px', + backgroundColor: 'white', + border: '1px solid #ccc', + borderRadius: '4px', + padding: '10px', + boxShadow: '0 2px 6px rgba(0,0,0,0.2)', + zIndex: 20, + minWidth: '170px', + maxWidth: 'fit-content', + overflow: 'auto', + }}> + <div + style={{ + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + marginBottom: '10px', + }}> + <label + style={{ + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '14px', + justifyContent: 'flex-end', + width: '100%', + }}> + /ask + <input type="checkbox" checked={this.askPromptsEnabled} onChange={this.toggleAskPrompts} style={{ margin: 0 }} /> + </label> + </div> + + <div + style={{ + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + marginBottom: '10px', + }}> + <label + style={{ + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '14px', + justifyContent: 'flex-end', + width: '100%', + }}> + Inline Prompting + <input type="checkbox" checked={this.inlinePromptsEnabled} onChange={this.toggleInlinePrompts} style={{ margin: 0 }} /> + </label> + </div> + + <button + onClick={() => { + this.showPromptMenu = false; + this.handleGeneratePrompts(); + }} + disabled={this.isLoadingPrompts} + style={{ + backgroundColor: '#9EAD7C', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: this.isLoadingPrompts ? 'not-allowed' : 'pointer', + opacity: this.isLoadingPrompts ? 0.6 : 1, + padding: '5px 10px', + float: 'right', + }}> + Generate Prompts + </button> + </div> + )} <FormattedTextBox ref={this._ref} {...this._props} fieldKey={'text'} Document={this.Document} TemplateDataDocument={undefined} /> </div> @@ -318,8 +514,10 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() } } +const ObservedDailyJournal = observer(DailyJournal); + Docs.Prototypes.TemplateMap.set(DocumentType.JOURNAL, { - layout: { view: DailyJournal, dataField: 'text' }, + layout: { view: ObservedDailyJournal, dataField: 'text' }, options: { acl: '', _height: 35, diff --git a/src/client/views/nodes/formattedText/DashFieldView.scss b/src/client/views/nodes/formattedText/DashFieldView.scss index 3734ad9cc..3ef3c2cef 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.scss +++ b/src/client/views/nodes/formattedText/DashFieldView.scss @@ -17,7 +17,7 @@ min-width: 12px; position: relative; display: inline-block; - margin: 0; + margin: 0px; transform: scale(0.7); background-color: rgba(155, 155, 155, 0.24); } @@ -53,7 +53,7 @@ .dashFieldView, .dashFieldView-active { .dashFieldView-select { - height: 10p; + height: 100%; font-size: 12px; background: transparent; opacity: 0; @@ -61,7 +61,7 @@ } } -.dashFieldView { +.dashFieldView-active { &:hover { .dashFieldView-select { opacity: unset; diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx index 7ea5d1fcf..6e3bdc5e8 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.tsx +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -262,6 +262,7 @@ export class DashFieldViewInternal extends ObservableReactComponent<IDashFieldVi render() { return ( <div + // eslint-disable-next-line no-use-before-define className={`dashFieldView${this.isRowActive() ? '-active' : ''}`} ref={this._fieldRef} style={{ diff --git a/src/client/views/nodes/formattedText/EquationEditor.scss b/src/client/views/nodes/formattedText/EquationEditor.scss index b0c17e56e..602135a30 100644 --- a/src/client/views/nodes/formattedText/EquationEditor.scss +++ b/src/client/views/nodes/formattedText/EquationEditor.scss @@ -32,7 +32,7 @@ margin-left: -1px; position: relative; z-index: 1; - padding: 0; + padding: 0px; display: -moz-inline-box; display: inline-block; } @@ -128,8 +128,8 @@ .mq-math-mode * { font-size: inherit; line-height: inherit; - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; border-color: black; -webkit-user-select: none; -moz-user-select: none; @@ -178,7 +178,7 @@ margin-left: 0.1em; } .mq-math-mode .mq-roman var.mq-f { - margin: 0; + margin: 0px; } .mq-math-mode big { font-size: 200%; @@ -323,7 +323,7 @@ padding: 0.1em; } .mq-math-mode .mq-sqrt-prefix { - padding-top: 0; + padding-top: 0px; position: relative; top: 0.1em; vertical-align: top; diff --git a/src/client/views/nodes/formattedText/EquationEditor.tsx b/src/client/views/nodes/formattedText/EquationEditor.tsx index 48efa6e63..23d273523 100644 --- a/src/client/views/nodes/formattedText/EquationEditor.tsx +++ b/src/client/views/nodes/formattedText/EquationEditor.tsx @@ -72,6 +72,10 @@ class EquationEditor extends Component<EquationEditorProps> { this.mathField.latex(value || ''); } + componentDidUpdate(prevProps: Readonly<EquationEditorProps>): void { + !prevProps.value && this.mathField.latex(this.props.value || ''); + } + render() { return <span ref={this.element} style={{ border: '0px', boxShadow: 'None' }} />; } diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index 547a2efa8..d5e566226 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -46,7 +46,7 @@ } audiotag { - left: 0; + left: 0px; position: absolute; cursor: pointer; border-radius: 10px; @@ -62,7 +62,7 @@ audiotag:hover { .formattedTextBox { touch-action: none; background: inherit; - padding: 0; + padding: 0px; border-width: 0px; border-color: global.$medium-gray; box-sizing: border-box; @@ -77,14 +77,14 @@ audiotag:hover { width: 100%; position: relative; transform-origin: left top; - top: 0; - left: 0; + top: 0px; + left: 0px; } .formattedTextBox-cont { touch-action: none; background: inherit; - padding: 0; + padding: 0px; border-width: 0px; border-radius: inherit; border-color: global.$medium-gray; @@ -111,7 +111,7 @@ audiotag:hover { .answer-tooltip { font-size: 15px; padding: 2px; - max-width: 150; + max-width: 150px; line-height: 150%; position: relative; } @@ -122,10 +122,10 @@ audiotag:hover { position: absolute; color: white; background: black; - right: 0; - bottom: 0; - width: 15; - height: 22; + right: 0px; + bottom: 0px; + width: 15px; + height: 22px; cursor: default; } @@ -139,8 +139,8 @@ audiotag:hover { .formattedTextBox-sidebar-handle { position: absolute; - top: 0; - right: 0; + top: 0px; + right: 0px; width: 20px; height: 20px; font-size: 11px; @@ -168,7 +168,7 @@ audiotag:hover { height: 100%; display: inline-block; position: absolute; - right: 0; + right: 0px; overflow: hidden; .collectionfreeformview-container { @@ -302,8 +302,8 @@ footnote::before { position: absolute; top: -0.5em; content: ' '; - height: 0; - width: 0; + height: 0px; + width: 0px; } .formattedTextBox-inlineComment { @@ -346,7 +346,7 @@ footnote::before { .prosemirror-linkBtn { background: unset; color: unset; - padding: 0; + padding: 0px; text-transform: unset; letter-spacing: unset; font-size: unset; @@ -357,7 +357,7 @@ footnote::before { background-color: dimgray; margin-top: 1.5em; z-index: 1; - padding: 5; + padding: 5px; border-radius: 2px; } .prosemirror-hrefoptions { @@ -396,7 +396,7 @@ footnote::before { blockquote { padding: 10px 10px; font-size: smaller; - margin: 0; + margin: 0px; font-style: italic; background: lightgray; border-left: solid 2px dimgray; @@ -415,7 +415,7 @@ footnote::before { p { font-family: inherit; } - margin-left: 0; + margin-left: 0px; } .bullet1 { p { @@ -439,7 +439,7 @@ footnote::before { display: inline-block; font-family: inherit; } - margin-left: 0; + margin-left: 0px; background-color: inherit; } .decimal2-ol { @@ -506,7 +506,7 @@ footnote::before { display: inline-block; font-family: inherit; } - margin-left: 0; + margin-left: 0px; padding-left: 1.2em; background-color: inherit; } @@ -661,7 +661,7 @@ footnote::before { .formattedTextBox-cont { touch-action: none; background: inherit; - padding: 0; + padding: 0px; border-width: 0px; border-radius: inherit; border-color: global.$medium-gray; @@ -706,7 +706,7 @@ footnote::before { height: 100%; display: inline-block; position: absolute; - right: 0; + right: 0px; .collectionfreeformview-container { position: relative; @@ -832,8 +832,8 @@ footnote::before { position: absolute; top: -0.5em; content: ' '; - height: 0; - width: 0; + height: px; + width: 0px; } .formattedTextBox-inlineComment { @@ -892,7 +892,7 @@ footnote::before { display: inline; font-family: inherit; } - margin-left: 0; + margin-left: 0px; } .decimal2-ol { counter-reset: deci2; @@ -952,7 +952,7 @@ footnote::before { display: inline; font-family: inherit; } - margin-left: 0; + margin-left: 0px; padding-left: 1.2em; } .multi2-ol { diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 57720baae..255ee1afe 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -13,7 +13,7 @@ import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transacti import { EditorView, NodeViewConstructor } from 'prosemirror-view'; import * as React from 'react'; import { BsMarkdownFill } from 'react-icons/bs'; -import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, removeStyleSheet, returnFalse, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils'; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, removeStyleSheet, returnFalse, returnTrue, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils'; import { DateField } from '../../../../fields/DateField'; import { CreateLinkToActiveAudio, Doc, DocListCast, Field, FieldType, Opt, StrListCast } from '../../../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DocCss, ForceServerWrite, UpdatingFromServer } from '../../../../fields/DocSymbols'; @@ -98,7 +98,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB public static PasteOnLoad: ClipboardEvent | undefined; public static SelectOnLoadChar = ''; - public static LiveTextUndo: UndoManager.Batch | undefined; // undo batch when typing a new text note into a collection + public static LiveTextUndo: UndoManager.Batch | undefined; // undo batch request when typing a new text note into a collection + private _liveTextUndo: UndoManager.Batch | undefined; // captured undo batch when typing a new text note into a collection private static _nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor }; private _curHighlights = new ObservableSet<string>(['Audio Tags']); @@ -270,13 +271,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB e.preventDefault(); e.stopPropagation(); const targetCreator = (annotationOn?: Doc) => { - const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn); + const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); + target.layout_fitWidth = true; DocumentView.SetSelectOnLoad(target); return target; }; + const sourceAnchorCreator = () => this.getAnchor(true); + const docView = this.DocumentView?.(); - docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, () => this.getAnchor(true), targetCreator), e.pageX, e.pageY); + docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY); }); AnchorMenu.Instance.AddDrawingAnnotation = (drawing: Doc) => { @@ -304,6 +308,21 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } }; + autoTag = () => { + const rawText = RTFCast(this.Document[this.fieldKey])?.Text ?? StrCast(this.Document[this.fieldKey]); + if (rawText && !this.Document.$tags_chat) { + const callType = rawText.includes('[placeholder]') ? GPTCallType.CLASSIFYTEXTMINIMAL : GPTCallType.CLASSIFYTEXTFULL; + + gptAPICall(rawText, callType).then( + action(desc => { + // Split GPT response into tokens and push individually & clear existing tags + this.Document.$tags_chat = new List<string>(desc.trim().split(/\s+/)); + this.Document._layout_showTags = true; + }) + ); + } + }; + leafText = (node: Node) => { if (node.type === this.EditorView?.state.schema.nodes.dashField) { const refDoc = !node.attrs.docId ? this.rootDoc : (DocServer.GetCachedRefField(node.attrs.docId as string) as Doc); @@ -365,6 +384,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB dataDoc[this.fieldKey] = numstring !== undefined ? Number(newText) : newText || (DocCast(dataDoc.proto)?.[this.fieldKey] === undefined && this.layoutDoc[this.fieldKey] === undefined) ? new RichTextField(newJson, newText) : undefined; textChange && ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.Document, text: newText }); + if (textChange) this.dataDoc.$tags_chat = undefined; this.ApplyingChange = ''; // turning this off here allows a Doc to retrieve data from template if noTemplate below is changed to false unchanged = false; } @@ -425,10 +445,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const newAutoLinks = new Set<Doc>(); const oldAutoLinks = Doc.Links(this.Document).filter( link => - ((!Doc.isTemplateForField(this.Document) && - ((DocCast(link.link_anchor_1) && !Doc.isTemplateForField(DocCast(link.link_anchor_1)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_1), this.Document)) && - ((DocCast(link.link_anchor_2) && !Doc.isTemplateForField(DocCast(link.link_anchor_2)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_2), this.Document))) || - (Doc.isTemplateForField(this.Document) && (link.link_anchor_1 === this.Document || link.link_anchor_2 === this.Document))) && + ((!Doc.IsTemplateForField(this.Document) && + ((DocCast(link.link_anchor_1) && !Doc.IsTemplateForField(DocCast(link.link_anchor_1)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_1), this.Document)) && + ((DocCast(link.link_anchor_2) && !Doc.IsTemplateForField(DocCast(link.link_anchor_2)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_2), this.Document))) || + (Doc.IsTemplateForField(this.Document) && (link.link_anchor_1 === this.Document || link.link_anchor_2 === this.Document))) && link.link_relationship === LinkManager.AutoKeywords ); // prettier-ignore if (this.EditorView?.state.doc.textContent) { @@ -1068,17 +1088,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB return anchorDoc ?? this.Document; } + showBorderRounding = returnTrue; getView = (doc: Doc, options: FocusViewOptions) => { if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { - if (!this.SidebarShown) { - this.toggleSidebar(false); - options.didMove = true; - } - setTimeout(() => this._sidebarRef?.current?.makeDocUnfiltered(doc)); + return SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, () => this.toggleSidebar(false), doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res)); }; focus = (textAnchor: Doc, options: FocusViewOptions) => { const focusSpeed = options.zoomTime ?? 500; @@ -1145,13 +1160,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB return undefined; }; - // if the scroll height has changed and we're in layout_autoHeight mode, then we need to update the textHeight component of the doc. - // Since we also monitor all component height changes, this will update the document's height. - resetNativeHeight = action((scrollHeight: number) => { - this.layoutDoc['_' + this.fieldKey + '_height'] = scrollHeight; - if (!this.layoutDoc.isTemplateForField && NumCast(this.layoutDoc._nativeHeight)) this.layoutDoc._nativeHeight = scrollHeight; - }); - addPlugin = (plugin: Plugin) => { const editorView = this.EditorView; if (editorView) { @@ -1182,21 +1190,31 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB this._disposers.width = reaction(this._props.PanelWidth, this.tryUpdateScrollHeight); this._disposers.scrollHeight = reaction( () => ({ scrollHeight: this.scrollHeight, layoutAutoHeight: this.layout_autoHeight, width: NumCast(this.layoutDoc._width) }), - ({ width, scrollHeight, layoutAutoHeight }) => width && layoutAutoHeight && this.resetNativeHeight(scrollHeight), + ({ width, scrollHeight, layoutAutoHeight }) => width && layoutAutoHeight && (this.layoutDoc['_' + this.fieldKey + '_height'] = scrollHeight), { fireImmediately: true } ); this._disposers.componentHeights = reaction( // set the document height when one of the component heights changes and layout_autoHeight is on - () => ({ border: this._props.PanelHeight(), sidebarHeight: this.sidebarHeight, textHeight: this.textHeight, layoutAutoHeight: this.layout_autoHeight, marginsHeight: this.layout_autoHeightMargins }), - ({ border, sidebarHeight, textHeight, layoutAutoHeight, marginsHeight }) => { + () => ({ + border: this._props.PanelHeight(), + scrollHeight: NumCast(this.layoutDoc['_' + this.fieldKey + '_height']), + sidebarHeight: this.sidebarHeight, + textHeight: this.textHeight, + layoutAutoHeight: this.layout_autoHeight, + marginsHeight: this.layout_autoHeightMargins, + }), + ({ border, sidebarHeight, scrollHeight, textHeight, layoutAutoHeight, marginsHeight }) => { const newHeight = this.contentScaling * (marginsHeight + Math.max(sidebarHeight, textHeight)); if ( (!Array.from(this._curHighlights).includes('Bold Text') || this._props.isSelected()) && // layoutAutoHeight && newHeight && - (newHeight !== this.layoutDoc.height || border < NumCast(this.layoutDoc.height)) && + (newHeight !== this.layoutDoc.height || border < NumCast(this.layoutDoc.height) || this.layoutDoc._nativeHeight !== scrollHeight) && !this._props.dontRegisterView ) { + if (NumCast(this.layoutDoc.nativeHeight)) { + this.layoutDoc._nativeHeight = scrollHeight; + } this._props.setHeight?.(newHeight); } }, @@ -1219,7 +1237,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const protoTime = protoData && this.dataDoc[this.fieldKey + '_autoUpdate'] ? (DateCast(DocCast(this.dataDoc.proto)?.[this.fieldKey + '_modificationDate'])?.date.getTime() ?? 0) : 0; const recentData = dataTime >= layoutTime ? (protoTime >= dataTime ? protoData : dataData) : layoutTime >= protoTime ? layoutData : protoData; const whichData = recentData ?? (this.layoutDoc.isTemplateDoc ? layoutData : protoData) ?? protoData; - return !whichData ? undefined : { data: RTFCast(whichData), str: Field.toString(DocCast(whichData) ?? StrCast(whichData)) }; + return !whichData ? undefined : { data: RTFCast(whichData), str: Field.toString(DocCast(whichData) ?? NumCast(whichData)?.toString() ?? StrCast(whichData)) }; }, incomingValue => { if (this.EditorView && this.ApplyingChange !== this.fieldKey) { @@ -1525,7 +1543,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } if (this.EditorView && selectOnLoad && !this._props.dontRegisterView && !this._props.dontSelectOnLoad && this.isActiveTab(this.ProseRef)) { const { $from } = this.EditorView.state.selection; - const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) }); + const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() }); const curMarks = this.EditorView.state.storedMarks ?? $from?.marksAcross(this.EditorView.state.selection.$head) ?? []; const storedMarks = [...curMarks.filter(m => m.type !== mark.type), mark]; if (selLoadChar === 'Enter') { @@ -1536,6 +1554,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data } if (selectOnLoad) { + this._liveTextUndo = FormattedTextBox.LiveTextUndo; + FormattedTextBox.LiveTextUndo = undefined; this.EditorView!.focus(); } if (this._props.isContentActive()) this.prepareForTyping(); @@ -1552,7 +1572,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const { text, paragraph } = schema.nodes; const selNode = this.EditorView.state.selection.$anchor.node(); if (this.EditorView.state.selection.from === 1 && this.EditorView.state.selection.empty && [undefined, text, paragraph].includes(selNode?.type)) { - const docDefaultMarks = [schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) })]; + const docDefaultMarks = [schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() })]; this.EditorView.state.selection.empty && this.EditorView.state.selection.from === 1 && this.EditorView?.dispatch(this.EditorView?.state.tr.setStoredMarks(docDefaultMarks).removeStoredMark(schema.marks.pFontColor)); } } @@ -1565,8 +1585,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB removeStyleSheet(this._userStyleSheetElement); Object.values(this._disposers).forEach(disposer => disposer?.()); this.endUndoTypingBatch(); - FormattedTextBox.LiveTextUndo?.end(); - FormattedTextBox.LiveTextUndo = undefined; + this._liveTextUndo?.end(); this.unhighlightSearchTerms(); this.EditorView?.destroy(); RichTextMenu.Instance?.TextView === this && RichTextMenu.Instance.updateMenu(undefined, undefined, undefined, undefined); @@ -1688,7 +1707,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB if (e.clientX > boundsRect.left && e.clientX < boundsRect.right && e.clientY > boundsRect.bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document editorView.focus(); - editorView.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, editorView.state.doc.content.size))); + // editorView.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, editorView.state.doc.content.size))); } } else if (node && [editorView.state.schema.nodes.ordered_list, editorView.state.schema.nodes.listItem].includes(node.type) && node !== (editorView.state.selection as NodeSelection)?.node && pcords) { editorView.dispatch(editorView.state.tr.setSelection(NodeSelection.create(editorView.state.doc, pcords.pos))); @@ -1770,7 +1789,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB if (this.ProseRef?.children[0] !== e.nativeEvent.target) return; if (!(this.EditorView?.state.selection instanceof NodeSelection) || this.EditorView.state.selection.node.type !== this.EditorView.state.schema.nodes.footnote) { const stordMarks = this.EditorView?.state.storedMarks?.slice(); - if (!(this.EditorView?.state.selection instanceof NodeSelection)) { + if (!(this.EditorView?.state.selection instanceof NodeSelection) && typeof this.dataDoc[this.fieldKey] !== 'number') { this.autoLink(); if (this.EditorView?.state.tr) { const tr = stordMarks?.reduce((tr2, m) => { @@ -1795,8 +1814,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB this.endUndoTypingBatch(); - FormattedTextBox.LiveTextUndo?.end(); - FormattedTextBox.LiveTextUndo = undefined; + this._liveTextUndo?.end(); // if the text box blurs and none of its contents are focused(), then pass the blur along setTimeout(() => !this.ProseRef?.contains(document.activeElement) && this._props.onBlur?.()); @@ -1817,7 +1835,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB return; } if (this._enteringStyle && 'tix!'.includes(e.key)) { - const tag = e.key === 't' ? 'todo' : e.key === 'i' ? 'ignore' : e.key === 'x' ? 'disagree' : e.key === '!' ? 'important' : '??'; const node = state.selection.$from.nodeAfter; const start = state.selection.from; const end = state.selection.to; @@ -1826,9 +1843,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB StopEvent(e); _editorView.dispatch( state.tr - .removeMark(start, end, schema.marks.user_mark) - .addMark(start, end, schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) })) - .addMark(start, end, schema.marks.user_tag.create({ userid: ClientUtils.CurrentUserEmail(), tag, modified: Math.round(Date.now() / 1000 / 60) })) + .removeMark(start, end, schema.marks.user_mark) // + .addMark(start, end, schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() })) ); return; } @@ -1861,9 +1877,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB break; default: if ([AclEdit, AclAugment, AclAdmin].includes(GetEffectiveAcl(this.Document))) { - const modified = Math.floor(Date.now() / 1000); - const mark = state.selection.$to.marks().find(m => m.type === schema.marks.user_mark && m.attrs.modified === modified); - _editorView.dispatch(state.tr.removeStoredMark(schema.marks.user_mark).addStoredMark(mark ?? schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified }))); + const mark = state.selection.$to.marks().find(m => m.type === schema.marks.user_mark); + _editorView.dispatch( + state.tr + .removeStoredMark(schema.marks.user_mark) // + .addStoredMark(mark ?? schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() })) + ); } break; } @@ -1887,14 +1906,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const margins = 2 * NumCast(this.layoutDoc._yMargin, this._props.yMargin || 0); const children = this.ProseRef?.children.length ? Array.from(this.ProseRef.children[0].children) : undefined; if (this.EditorView && children && !SnappingManager.IsDragging) { - const getChildrenHeights = (kids: Element[] | undefined) => kids?.reduce((p, child) => p + toHgt(child), margins) ?? 0; + const getChildrenHeights = (kids: Element[] | undefined) => kids?.reduce((p, child) => p + toHgt(child), 0) ?? 0; const toNum = (val: string) => Number(val.replace('px', '')); const toHgt = (node: Element): number => { const { height, marginTop, marginBottom } = getComputedStyle(node); const childHeight = height === 'auto' ? getChildrenHeights(Array.from(node.children)) : toNum(height); return childHeight + Math.max(0, toNum(marginTop)) + Math.max(0, toNum(marginBottom)); }; - const proseHeight = !this.ProseRef ? 0 : getChildrenHeights(children); + const proseHeight = !this.ProseRef ? 0 : getChildrenHeights(children) + margins; const scrollHeight = this.ProseRef && proseHeight; if (this._props.setHeight && !this._props.suppressSetHeight && scrollHeight && !this._props.dontRegisterView) { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation @@ -2110,6 +2129,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } }; + setRef = (r: HTMLDivElement | null) => this.fixWheelEvents(r, this._props.isContentActive, this.onPassiveWheel); + setScrollRef = (r: HTMLDivElement | null) => (this._scrollRef = r); render() { TraceMobx(); const scale = this.nativeScaling(); @@ -2124,7 +2145,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB ) : styleFromLayout?.height === '0px' ? null : ( <div className="formattedTextBox" - ref={r => this.fixWheelEvents(r, this._props.isContentActive, this.onPassiveWheel)} + ref={this.setRef} style={{ ...(this._props.dontScale ? {} @@ -2162,9 +2183,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB onDoubleClick={this.onDoubleClick}> <div className="formattedTextBox-outer" - ref={r => { - this._scrollRef = r; - }} + ref={this.setScrollRef} style={{ width: this.noSidebar ? '100%' : `calc(100% - ${this.sidebarWidthPercent})`, overflow: this.layoutDoc._createDocOnCR || this.layoutDoc._layout_hideScroll ? 'hidden' : this.layout_autoHeight ? 'visible' : undefined, diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss index bc0810f22..92f3e3290 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss @@ -11,8 +11,8 @@ -webkit-transform: translateX(-50%); transform: translateX(-50%); box-shadow: 3px 3px 1.5px grey; - max-width: 400; - max-height: 235; + max-width: 400px; + max-height: 235px; height: max-content; .formattedTextBox-tooltipText { height: max-content; @@ -22,26 +22,26 @@ .formattedTextBox-tooltip:before { content: ''; - height: 0; - width: 0; + height: 0px; + width: 0px; position: absolute; left: 50%; margin-left: -5px; bottom: -6px; border: 5px solid transparent; - border-bottom-width: 0; + border-bottom-width: 0px; border-top-color: silver; } .formattedTextBox-tooltip:after { content: ''; - height: 0; - width: 0; + height: 0px; + width: 0px; position: absolute; left: 50%; margin-left: -5px; bottom: -4.5px; border: 5px solid transparent; - border-bottom-width: 0; + border-bottom-width: 0px; border-top-color: white; } diff --git a/src/client/views/nodes/formattedText/RichTextMenu.scss b/src/client/views/nodes/formattedText/RichTextMenu.scss index fcc816447..7c747de1e 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.scss +++ b/src/client/views/nodes/formattedText/RichTextMenu.scss @@ -23,7 +23,7 @@ .dropdown { position: absolute; top: 35px; - left: 0; + left: 0px; background-color: #323232; color: global.$light-gray; border: 1px solid #4d4d4d; @@ -47,7 +47,7 @@ } &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } } } diff --git a/src/client/views/nodes/formattedText/TooltipTextMenu.scss b/src/client/views/nodes/formattedText/TooltipTextMenu.scss index 87320943d..8980a93a2 100644 --- a/src/client/views/nodes/formattedText/TooltipTextMenu.scss +++ b/src/client/views/nodes/formattedText/TooltipTextMenu.scss @@ -195,7 +195,7 @@ left: 1px; width: 24px; height: 4px; - margin-top: 0; + margin-top: 0px; } } @@ -221,7 +221,7 @@ display: inline-block; width: 1em; height: 1em; - stroke-width: 0; + stroke-width: 0px; stroke: currentColor; fill: currentColor; margin-right: 15px; @@ -231,7 +231,7 @@ display: inline-block; width: 1em; height: 1em; - stroke-width: 3; + stroke-width: 3px; fill: greenyellow; margin-right: 15px; } @@ -270,7 +270,7 @@ &.ProseMirror-menu-dropdown { width: 10px; height: 25px; - margin: 0; + margin: 0px; padding: 0 2px; background-color: #323232; text-align: center; diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts index b7dae1ca3..333ee6be8 100644 --- a/src/client/views/nodes/formattedText/marks_rts.ts +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -330,20 +330,6 @@ export const marks: { [index: string]: MarkSpec } = { return ['span', { class: 'UM-' + uid + remote + ' UM-min-' + min + ' UM-hr-' + hr + ' UM-day-' + day }, 0]; }, }, - // the id of the user who entered the text - user_tag: { - attrs: { - userid: { default: '' }, - modified: { default: 'when?' }, // 1 second intervals since 1970 - tag: { default: '' }, - }, - group: 'inline', - inclusive: false, - toDOM: node => { - const uid = node.attrs.userid.replace('.', '').replace('@', ''); - return ['span', { class: 'UT-' + uid + ' UT-' + node.attrs.tag }, 0]; - }, - }, // :: MarkSpec Code font mark. Represented as a `<code>` element. code: { diff --git a/src/client/views/nodes/imageEditor/ImageEditor.scss b/src/client/views/nodes/imageEditor/ImageEditor.scss index c691e6a18..942a7d4c6 100644 --- a/src/client/views/nodes/imageEditor/ImageEditor.scss +++ b/src/client/views/nodes/imageEditor/ImageEditor.scss @@ -4,8 +4,8 @@ $scale: 0.5; .imageEditorContainer { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 9999; height: 100vh; width: 100vw; @@ -78,7 +78,7 @@ $scale: 0.5; .sideControlsContainer { width: 160px; position: absolute; - left: 0; + left: 0px; height: 100%; .sideControls { @@ -129,8 +129,8 @@ $scale: 0.5; .originalImageLabel { position: absolute; - bottom: 10; - left: 10; + bottom: 10px; + left: 10px; color: #ffffff; font-size: 0.8rem; letter-spacing: 1px; diff --git a/src/client/views/nodes/scrapbook/AIPresetGenerator.ts b/src/client/views/nodes/scrapbook/AIPresetGenerator.ts new file mode 100644 index 000000000..1f159222b --- /dev/null +++ b/src/client/views/nodes/scrapbook/AIPresetGenerator.ts @@ -0,0 +1,31 @@ +import { ScrapbookItemConfig } from './ScrapbookPreset'; +import { GPTCallType, gptAPICall } from '../../../apis/gpt/GPT'; + +// Represents the descriptor for each document +export interface DocumentDescriptor { + type: string; + tags: string[]; +} + +// Main function to request AI-generated presets +export async function requestAiGeneratedPreset(descriptors: DocumentDescriptor[]): Promise<ScrapbookItemConfig[]> { + const prompt = createPrompt(descriptors); + let aiResponse = await gptAPICall(prompt, GPTCallType.GENERATESCRAPBOOK); + // Strip out ```json and ``` if the model wrapped its answer in fences + aiResponse = aiResponse + .trim() + .replace(/^```(?:json)?\s*/, "") // remove leading ``` or ```json + .replace(/\s*```$/, ""); // remove trailing ``` + const parsedPreset = JSON.parse(aiResponse) as ScrapbookItemConfig[]; + return parsedPreset; +} + +// Helper to generate prompt text for AI +function createPrompt(descriptors: DocumentDescriptor[]): string { + let prompt = ""; + descriptors.forEach((desc, index) => { + prompt += `${index + 1}. Type: ${desc.type}, Tags: ${desc.tags.join(', ')}\n`; + }); + + return prompt; +} diff --git a/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx b/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx deleted file mode 100644 index e99bf67c7..000000000 --- a/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx +++ /dev/null @@ -1,52 +0,0 @@ -//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION -import * as React from "react"; -import { observer } from "mobx-react"; -import { Doc } from "../../../../fields/Doc"; -import { DocumentView } from "../DocumentView"; -import { Transform } from "../../../util/Transform"; - -interface EmbeddedDocViewProps { - doc: Doc; - width?: number; - height?: number; - slotId?: string; -} - -@observer -export class EmbeddedDocView extends React.Component<EmbeddedDocViewProps> { - render() { - const { doc, width = 300, height = 200, slotId } = this.props; - - // Use either an existing embedding or create one - let docToDisplay = doc; - - // If we need an embedding, create or use one - if (!docToDisplay.isEmbedding) { - docToDisplay = Doc.BestEmbedding(doc) || Doc.MakeEmbedding(doc); - // Set the container to the slot's ID so we can track it - if (slotId) { - docToDisplay.embedContainer = `scrapbook-slot-${slotId}`; - } - } - - return ( - <DocumentView - Document={docToDisplay} - renderDepth={0} - // Required sizing functions - NativeWidth={() => width} - NativeHeight={() => height} - PanelWidth={() => width} - PanelHeight={() => height} - // Required state functions - isContentActive={() => true} - childFilters={() => []} - ScreenToLocalTransform={() => new Transform()} - // Display options - hideDeleteButton={true} - hideDecorations={true} - hideResizeHandles={true} - /> - ); - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.scss b/src/client/views/nodes/scrapbook/ScrapbookBox.scss new file mode 100644 index 000000000..6ac2220f9 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.scss @@ -0,0 +1,66 @@ +.scrapbook-box { + /* Make sure the container fills its parent, and set a base background */ + position: relative; /* so that absolute children (loading overlay, etc.) are positioned relative to this */ + width: 100%; + height: 100%; + background: beige; + overflow: hidden; /* prevent scrollbars if children overflow */ +} + +/* Loading overlay that covers the entire scrapbook while AI-generation is in progress */ +.scrapbook-box-loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: rgba(255, 255, 255, 0.8); + z-index: 10; /* sits above the ImageBox and other content */ +} + +/* The <select> dropdown for choosing presets */ +.scrapbook-box-preset-select { + position: relative; + top: 8px; + left: 8px; + z-index: 20; + padding: 4px 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 4px; + background: white; +} + +/* Container for the “Regenerate Background” button */ +.scrapbook-box-ui { + position: relative; + top: 8px; + right: 8px; + z-index: 20; + background: white; + width: 40px; + display: flex; + justify-content: center; +} + +/* The button itself */ +.scrapbook-box-ui-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + font-size: 14px; + color: black; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.scrapbook-box-ui-button:hover { + background: #f5f5f5; +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx index 6cfe9a62c..d0ae6194f 100644 --- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx @@ -1,130 +1,260 @@ -import { action, makeObservable, observable } from 'mobx'; +import { IconButton, Size } from '@dash/components'; +import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; import * as React from 'react'; -import { Doc, DocListCast } from '../../../../fields/Doc'; +import ReactLoading from 'react-loading'; +import { Doc, DocListCast, Opt, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; +import { DateCast, DocCast, NumCast, toList } from '../../../../fields/Types'; import { emptyFunction } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; +import { DragManager } from '../../../util/DragManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { undoable } from '../../../util/UndoManager'; import { CollectionView } from '../../collections/CollectionView'; import { ViewBoxAnnotatableComponent } from '../../DocComponent'; +import { AspectRatioLimits, FireflyImageDimensions } from '../../smartdraw/FireflyConstants'; +import { SmartDrawHandler } from '../../smartdraw/SmartDrawHandler'; import { DocumentView } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; -import { DragManager } from '../../../util/DragManager'; -import { RTFCast, StrCast, toList } from '../../../../fields/Types'; -import { undoable } from '../../../util/UndoManager'; -// Scrapbook view: a container that lays out its child items in a grid/template -export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { - @observable createdDate: string; +import { ImageBox } from '../ImageBox'; +import './ScrapbookBox.scss'; +import { ScrapbookItemConfig } from './ScrapbookPreset'; +import { createPreset, getPresetNames } from './ScrapbookPresetRegistry'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { DocUtils } from '../../../documents/DocUtils'; +import { returnTrue } from '../../../../ClientUtils'; - constructor(props: FieldViewProps) { - super(props); - makeObservable(this); - this.createdDate = this.getFormattedDate(); +function createPlaceholder(cfg: ScrapbookItemConfig, doc: Doc) { + const placeholder = new Doc(); + placeholder.proto = doc; + placeholder.original = doc; + placeholder.x = cfg.x; + placeholder.y = cfg.y; + if (cfg.width !== null) placeholder._width = cfg.width; + if (cfg.height !== null) placeholder._height = cfg.height; + return placeholder; +} - // ensure we always have a List<Doc> in dataDoc['items'] - if (!this.dataDoc[this.fieldKey]) { - this.dataDoc[this.fieldKey] = new List<Doc>(); +function createMessagePlaceholder(cfg: ScrapbookItemConfig) { + return createPlaceholder(cfg, + Docs.Create.TextDocument(cfg.message ?? ('[placeholder] ' + cfg.acceptTags?.[0]), { placeholder: "", placeholder_docType: cfg.type, placeholder_acceptTags: new List<string>(cfg.acceptTags) }) + ); // prettier-ignore +} +export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]) { + return configs.map(cfg => { + if (cfg.children?.length) { + const childDocs = cfg.children.map(createMessagePlaceholder); + const protoW = cfg.containerWidth ?? cfg.width; + const protoH = cfg.containerHeight ?? cfg.height; + // Create a stacking document with the child placeholders + const containerProto = Docs.Create.StackingDocument(childDocs, { + ...(protoW !== null ? { _width: protoW } : {}), + ...(protoH !== null ? { _height: protoH } : {}), + title: cfg.message, + }); + return createPlaceholder(cfg, containerProto); } - this.createdDate = this.getFormattedDate(); - this.setTitle(); + return createMessagePlaceholder(cfg); + }); +} +export async function slotRealDocIntoPlaceholders(realDoc: Doc, placeholders: Doc[]) { + if (!realDoc.$tags_chart) { + await DocumentView.getFirstDocumentView(realDoc)?.ComponentView?.autoTag?.(); } + const realTags = new Set<string>(StrListCast(realDoc.$tags_chat).map(t => t.toLowerCase?.() ?? '')); + // Find placeholder with most matching tags + let bestMatch: Doc | null = null; + let maxMatches = 0; + + // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type + placeholders + .filter(ph => ph.placeholder_docType === realDoc.$type) // Skip this placeholder entirely if types do not match. + .forEach(ph => { + const matches = StrListCast(ph.placeholder_acceptTags) + .map(t => t.toLowerCase?.()) + .filter(tag => realTags.has(tag)); + + if (matches.length > maxMatches) { + maxMatches = matches.length; + bestMatch = ph; + } + }); + + if (bestMatch && maxMatches > 0) { + setTimeout(undoable(() => (bestMatch!.proto = realDoc), 'Scrapbook add')); + return true; + } + + return false; +} + +// Scrapbook view: a container that lays out its child items in a template +@observer +export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(ScrapbookBox, fieldStr); } + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _imageBoxRef = React.createRef<ImageBox>(); + + constructor(props: FieldViewProps) { + super(props); + makeObservable(this); + } + + @observable _selectedPreset = getPresetNames()[0]; + @observable _loading = false; - getFormattedDate(): string { - return new Date().toLocaleDateString(undefined, { + @computed get createdDate() { + return DateCast(this.dataDoc.$author_date)?.date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } + @computed get ScrapbookLayoutDocs() { return DocListCast(this.dataDoc[this.fieldKey]); } // prettier-ignore + @computed get BackgroundDoc() { return DocCast(this.dataDoc[this.fieldKey + '_background']); } // prettier-ignore + set ScrapbookLayoutDocs(doc: Doc[]) { this.dataDoc[this.fieldKey] = new List(doc); } // prettier-ignore + set BackgroundDoc(doc: Opt<Doc>) { this.dataDoc[this.fieldKey + '_background'] = doc; } // prettier-ignore @action - setTitle() { - const title = `Scrapbook - ${this.createdDate}`; - if (this.dataDoc.title !== title) { - this.dataDoc.title = title; - - const image = Docs.Create.TextDocument('image'); - image.accepts_docType = DocumentType.IMG; - const placeholder = new Doc(); - placeholder.proto = image; - placeholder.original = image; - placeholder._width = 250; - placeholder._height = 200; - placeholder.x = 0; - placeholder.y = -100; - //placeholder.overrideFields = new List<string>(['x', 'y']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - - const summary = Docs.Create.TextDocument('summary'); - summary.accepts_docType = DocumentType.RTF; - summary.accepts_textType = 'one line'; - const placeholder2 = new Doc(); - placeholder2.proto = summary; - placeholder2.original = summary; - placeholder2.x = 0; - placeholder2.y = 200; - placeholder2._width = 250; - //placeholder2.overrideFields = new List<string>(['x', 'y', '_width']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - this.dataDoc[this.fieldKey] = new List<Doc>([placeholder, placeholder2]); - } - } + setDefaultPlaceholder = () => { + this.ScrapbookLayoutDocs = [ + createMessagePlaceholder({ + message: 'To create a scrapbook from existing documents, marquee select. For existing scrapbook arrangements, select a preset from the dropdown.', + type: DocumentType.RTF, + width: 250, + height: 200, + x: 0, + y: 0, + }), + ]; + + const placeholder1 = createMessagePlaceholder({ acceptTags: ['PERSON'], type: DocumentType.IMG, width: 250, height: 200, x: 0, y: -100 }); + const placeholder2 = createMessagePlaceholder({ acceptTags: ['lengthy description'], type: DocumentType.RTF, width: 250, height: undefined, x: 0, y: 200 }); + const placeholder3 = createMessagePlaceholder({ acceptTags: ['title'], type: DocumentType.RTF, width: 50, height: 200, x: 280, y: -50 }); + const placeholder4 = createPlaceholder( { width: 100, height: 200, x: -200, y: -100 }, Docs.Create.StackingDocument([ + createMessagePlaceholder({ acceptTags: ['LANDSCAPE'], type: DocumentType.IMG, width: 50, height: 100, x: 0, y: -100 }) + ], { _width: 300, _height: 300, title: 'internal coll' })); // prettier-ignore + console.log('UNUSED', placeholder4, placeholder3, placeholder2, placeholder1); + /* note-to-self + would doing: + const collection = Docs.Create.ScrapbookDocument([placeholder, placeholder2, placeholder3]); + create issues with references to the same object? */ + + /*note-to-self + Should we consider that there are more collections than just COL type collections? + when spreading */ + + /*note-to-self + difference between passing a new List<Doc> versus just the raw array? */ + }; + + selectPreset = action((presetName: string) => (this.ScrapbookLayoutDocs = buildPlaceholdersFromConfigs(createPreset(presetName)))); componentDidMount() { - this.setTitle(); + const title = `Scrapbook - ${this.createdDate}`; + if (!this.ScrapbookLayoutDocs.length) this.setDefaultPlaceholder(); + if (!this.BackgroundDoc) this.generateAiImage(this.regenPrompt); + if (this.dataDoc.title !== title) this.dataDoc.title = title; // ensure title is set + + this._disposers.propagateResize = reaction( + () => ({ w: this.layoutDoc._width, h: this.layoutDoc._height }), + (dims, prev) => { + const imageBox = this._imageBoxRef.current; + // prev is undefined on the first run + if (prev && SnappingManager.ShiftKey && this.BackgroundDoc && imageBox) { + this.BackgroundDoc[imageBox.fieldKey + '_outpaintOriginalWidth'] = prev.w; + this.BackgroundDoc[imageBox.fieldKey + '_outpaintOriginalHeight'] = prev.h; + imageBox.layoutDoc._width = dims.w; + imageBox.layoutDoc._height = dims.h; + } + } + ); } - childRejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => { - return true; // disable dropping documents onto any child of the scrapbook. - }; - rejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => { - // Test to see if the dropped doc is dropped on an acceptable location (anywerhe? on a specific box). - // const draggedDocs = de.complete.docDragData?.draggedDocuments; - return false; // allow all Docs to be dropped onto scrapbook -- let filterAddDocument make the final decision. + isOutpaintable = returnTrue; + showBorderRounding = returnTrue; + + @action + generateAiImage = (prompt: string) => { + this._loading = true; + + const ratio = NumCast(this.layoutDoc._width, 1) / NumCast(this.layoutDoc._height, 1); // Measure the scrapbook’s current aspect + const choosePresetForDimensions = (() => { // Pick the Firefly preset that best matches the aspect ratio + if (ratio > AspectRatioLimits[FireflyImageDimensions.Widescreen]) return FireflyImageDimensions.Widescreen; + if (ratio > AspectRatioLimits[FireflyImageDimensions.Landscape]) return FireflyImageDimensions.Landscape; + if (ratio < AspectRatioLimits[FireflyImageDimensions.Portrait]) return FireflyImageDimensions.Portrait; + return FireflyImageDimensions.Square; + })(); // prettier-ignore + + SmartDrawHandler.CreateWithFirefly(prompt, choosePresetForDimensions) // Call exactly the same CreateWithFirefly that ImageBox uses + .then(action(doc => { + if (doc instanceof Doc) { + this.BackgroundDoc = doc; // set the background image directly on the scrapbook + } else { + alert('Failed to generate document.'); + } + })) + .catch(e => alert(`Generation error: ${e}`)) + .finally(action(() => (this._loading = false))); // prettier-ignore }; - filterAddDocument = (docIn: Doc | Doc[]) => { - const docs = toList(docIn); - if (docs?.length === 1) { - const placeholder = DocListCast(this.dataDoc[this.fieldKey]).find(d => - (d.accepts_docType === docs[0].$type || // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type - RTFCast(d[Doc.LayoutDataKey(d)])?.Text.includes(StrCast(docs[0].$type))) - ); // prettier-ignore - - if (placeholder) { - // ugh. we have to tell the underlying view not to add the Doc so that we can add it where we want it. - // However, returning 'false' triggers an undo. so this settimeout is needed to make the assignment happen after the undo. - setTimeout( - undoable(() => { - //StrListCast(placeholder.overrideFields).map(field => (docs[0][field] = placeholder[field])); // // shouldn't need to do this for layout fields since the placeholder already overrides its protos - placeholder.proto = docs[0]; - }, 'Scrapbook add') - ); - return false; - } - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + childRejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => true; // disable dropping documents onto any child of the scrapbook. + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + rejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => false; // allow all Docs to be dropped onto scrapbook -- let filterAddDocument make the final decision. + + /** + * Filter function to determine if a document can be added to the scrapbook. + * This checks if the document matches any of the placeholder slots in the scrapbook. + * @param docs - The document(s) being added to the scrapbook. + * @returns true if the document can be added, false otherwise. + */ + filterAddDocument = (docs: Doc | Doc[]) => { + toList(docs).forEach(doc => slotRealDocIntoPlaceholders(doc, DocUtils.unwrapPlaceholders(this.ScrapbookLayoutDocs))); return false; }; + @computed get regenPrompt() { + const allDocs = DocUtils.unwrapPlaceholders(this.ScrapbookLayoutDocs); // find all non-collections in scrapbook (e.g., placeholder content docs) + const internalTagsSet = new Set<string>(allDocs.flatMap(doc => StrListCast(doc.$tags_chat).filter(tag => !tag.startsWith?.('ASPECT_')))); + const internalTags = Array.from(internalTagsSet).join(', '); + + return internalTags ? `Create a new scrapbook background featuring: ${internalTags}` : 'A serene mountain landscape at sunrise, ultra-wide, pastel sky, abstract, scrapbook background'; + } + render() { return ( - <div style={{ background: 'beige', width: '100%', height: '100%' }}> - <CollectionView - {...this._props} // - setContentViewBox={emptyFunction} - rejectDrop={this.rejectDrop} - childRejectDrop={this.childRejectDrop} - filterAddDocument={this.filterAddDocument} - /> - {/* <div style={{ border: '1px black', borderStyle: 'dotted', position: 'absolute', top: '50%', width: '100%', textAlign: 'center' }}>Drop an image here</div> */} + <div className="scrapbook-box"> + <div style={{ display: this._loading ? undefined : 'none' }} className="scrapbook-box-loading-overlay"> + <ReactLoading type="spin" width={50} height={50} /> + </div> + + {this.BackgroundDoc && <ImageBox ref={this._imageBoxRef} {...this._props} Document={this.BackgroundDoc} fieldKey="data" />} + <div style={{ display: this._props.isContentActive() ? 'flex' : 'none', alignItems: 'center', justifyContent: 'space-between', padding: '0 10px' }}> + <select className="scrapbook-box-preset-select" value={this._selectedPreset} onChange={action(e => this.selectPreset((this._selectedPreset = e.currentTarget.value)))}> + {getPresetNames().map(name => ( + <option key={name} value={name}> + {name} + </option> + ))} + </select> + <div className="scrapbook-box-ui" style={{ opacity: this._loading ? 0.5 : 1 }}> + <IconButton size={Size.SMALL} tooltip="regenerate a new background" label="back-ground" icon={<FontAwesomeIcon icon="redo-alt" size="sm" />} onClick={() => !this._loading && this.generateAiImage(this.regenPrompt)} /> + </div> + </div> + + <CollectionView {...this._props} setContentViewBox={emptyFunction} rejectDrop={this.rejectDrop} childRejectDrop={this.childRejectDrop} filterAddDocument={this.filterAddDocument} /> </div> ); } } -// Register scrapbook Docs.Prototypes.TemplateMap.set(DocumentType.SCRAPBOOK, { layout: { view: ScrapbookBox, dataField: 'items' }, options: { diff --git a/src/client/views/nodes/scrapbook/ScrapbookContent.tsx b/src/client/views/nodes/scrapbook/ScrapbookContent.tsx deleted file mode 100644 index ad1d308e8..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookContent.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react-lite"; -// Import the Doc type from your actual module. -import { Doc } from "../../../../fields/Doc"; - -export interface ScrapbookContentProps { - doc: Doc; -} - -// A simple view that displays a document's title and content. -// Adjust how you extract the text if your Doc fields are objects. -export const ScrapbookContent: React.FC<ScrapbookContentProps> = observer(({ doc }) => { - // If doc.title or doc.content are not plain strings, convert them. - const titleText = doc.title ? doc.title.toString() : "Untitled"; - const contentText = doc.content ? doc.content.toString() : "No content available."; - - return ( - <div className="scrapbook-content"> - <h3>{titleText}</h3> - <p>{contentText}</p> - </div> - ); -}); diff --git a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx new file mode 100644 index 000000000..a3405083b --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx @@ -0,0 +1,94 @@ +import { DocumentType } from '../../../documents/DocumentTypes'; + +export enum ScrapbookPresetType { + None = 'None', + Collage = 'Collage', + Spotlight = 'Spotlight', + Gallery = 'Gallery', + Default = 'Default', + Classic = 'Classic', +} + +export interface ScrapbookItemConfig { + x: number; + y: number; + + message?: string; // optional text to display instead of [placeholder] + acceptTags[0] + type?: DocumentType; + /** what this slot actually accepts (defaults to `tag`) */ + acceptTags?: string[]; + /** the frame this placeholder occupies */ + width?: number; + height?: number; + /** if this is a container with children, use these for the proto’s own size */ + containerWidth?: number; + containerHeight?: number; + children?: ScrapbookItemConfig[]; +} + +export class ScrapbookPreset { + static createPreset(presetType: ScrapbookPresetType): ScrapbookItemConfig[] { + switch (presetType) { + case ScrapbookPresetType.None: return ScrapbookPreset.createNonePreset(); + case ScrapbookPresetType.Classic: return ScrapbookPreset.createClassicPreset(); + case ScrapbookPresetType.Collage: return ScrapbookPreset.createCollagePreset(); + case ScrapbookPresetType.Spotlight: return ScrapbookPreset.createSpotlightPreset(); + case ScrapbookPresetType.Default: return ScrapbookPreset.createDefaultPreset(); + case ScrapbookPresetType.Gallery: return ScrapbookPreset.createGalleryPreset(); + default: + throw new Error(`Unknown preset type: ${presetType}`); + } // prettier-ignore + } + + private static createNonePreset(): ScrapbookItemConfig[] { + return [{ message: 'To create a scrapbook from existing documents, marquee select. For existing scrapbook arrangements, select a preset from the dropdown.', type: DocumentType.RTF, acceptTags: [], x: 0, y: 0, width: 250, height: 200 }]; + } + + private static createClassicPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: '[placeholder] landscape', acceptTags: ['LANDSCAPE'], x: 0, y: -100, width: 250, height: 200 }, + { type: DocumentType.RTF, message: '[placeholder] lengthy caption', acceptTags: ['paragraphs'], x: 0, y: 138, width: 250, height: 172 }, + { type: DocumentType.RTF, message: '[placeholder] brief description', acceptTags: ['sentence'], x: 280, y: -50, width: 50, height: 200 }, + { type: DocumentType.IMG, message: '[placeholder] person', acceptTags: ['PERSON'], x: -200, y: -100, width: 167, height: 200 }, + ]; + } + + private static createGalleryPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: 'Gallery 1 <drop person images into the gallery!>', acceptTags: ['PERSON'], x: -150, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 2', acceptTags: ['PERSON'], x: 0, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 3', acceptTags: ['PERSON'], x: 150, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 4', acceptTags: ['PERSON'], x: -150, y: 0, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 5', acceptTags: ['PERSON'], x: 0, y: 0, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 6', acceptTags: ['PERSON'], x: 150, y: 0, width: 150, height: 150 }, + ]; + } + + private static createDefaultPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: 'drop a landscape image', acceptTags: ['LANDSCAPE'], x: 44, y: -50, width: 200, height: 120 }, + { type: DocumentType.PDF, message: 'summary pdf', acceptTags: ['word', 'sentence', 'paragraphs'], x: 45, y: 93, width: 184, height: 273 }, + { type: DocumentType.RTF, message: 'sidebar text', acceptTags: ['paragraphs'], x: 250, y: -50, width: 100, height: 200 }, + { containerWidth: 200, containerHeight: 425, x: -171, y: -54, width: 200, height: 425, + children: [{ type: DocumentType.IMG, message: 'drop a person image', acceptTags: ['PERSON'], x: -350, y: 200, width: 162, height: 137 }], }, + ]; // prettier-ignore + } + + private static createCollagePreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: 'landscape image', acceptTags: ['LANDSCAPE'], x: -174, y: 100, width: 160, height: 150 }, + { type: DocumentType.IMG, message: 'person image', acceptTags: ['PERSON'], x: 0, y: 100, width: 150, height: 150 }, + { type: DocumentType.RTF, message: 'caption', acceptTags: ['sentence'], x: -174, y: 50, width: 150, height: 40 }, + { type: DocumentType.RTF, message: 'caption', acceptTags: ['sentence'], x: 0, y: 50, width: 150, height: 40 }, + { type: DocumentType.RTF, message: 'lengthy description', acceptTags: ['paragraphs'], x: -180, y: -60, width: 350, height: 100 }, + ]; // prettier-ignore + } + + private static createSpotlightPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.RTF, message: 'title text', acceptTags: ['word'], x: 0, y: -30, width: 300, height: 40 }, + { type: DocumentType.IMG, message: 'drop a landscape image', acceptTags: ['LANDSCAPE'], x: 0, y: 20, width: 300, height: 200 }, + { type: DocumentType.RTF, message: 'caption text', acceptTags: ['sentence'], x: 0, y: 230, width: 300, height: 50 }, + ]; + } +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts b/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts new file mode 100644 index 000000000..3a2189d00 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts @@ -0,0 +1,36 @@ +import { ScrapbookItemConfig } from './ScrapbookPreset'; +import { ScrapbookPresetType } from './ScrapbookPreset'; + +type PresetGenerator = () => ScrapbookItemConfig[]; + +// Internal map of preset name to generator +const presetRegistry = new Map<string, PresetGenerator>(); + +/** + * Register a new scrapbook preset under the given name. + */ +export function registerPreset(name: string, gen: PresetGenerator) { + presetRegistry.set(name, gen); +} + +/** + * List all registered preset names. + */ +export function getPresetNames(): string[] { + return Array.from(presetRegistry.keys()); +} + +/** + * Create the config array for the named preset. + */ +export function createPreset(name: string): ScrapbookItemConfig[] { + const gen = presetRegistry.get(name); + if (!gen) throw new Error(`Unknown scrapbook preset: ${name}`); + return gen(); +} + +// ------------------------ +// Register built-in presets +import { ScrapbookPreset } from './ScrapbookPreset'; + +Object.keys(ScrapbookPresetType).forEach(key => registerPreset(key, () => ScrapbookPreset.createPreset(key as ScrapbookPresetType))); // pretter-ignore diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlot.scss b/src/client/views/nodes/scrapbook/ScrapbookSlot.scss deleted file mode 100644 index ae647ad36..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookSlot.scss +++ /dev/null @@ -1,85 +0,0 @@ -//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION -.scrapbook-slot { - position: absolute; - background-color: rgba(245, 245, 245, 0.7); - border: 2px dashed #ccc; - border-radius: 5px; - box-sizing: border-box; - transition: all 0.2s ease; - overflow: hidden; - - &.scrapbook-slot-over { - border-color: #4a90e2; - background-color: rgba(74, 144, 226, 0.1); - } - - &.scrapbook-slot-filled { - border-style: solid; - border-color: rgba(0, 0, 0, 0.1); - background-color: transparent; - - &.scrapbook-slot-over { - border-color: #4a90e2; - background-color: rgba(74, 144, 226, 0.1); - } - } - - .scrapbook-slot-empty { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - } - - .scrapbook-slot-placeholder { - text-align: center; - color: #888; - } - - .scrapbook-slot-title { - font-weight: bold; - margin-bottom: 5px; - } - - .scrapbook-slot-instruction { - font-size: 0.9em; - font-style: italic; - } - - .scrapbook-slot-content { - width: 100%; - height: 100%; - position: relative; - } - - .scrapbook-slot-controls { - position: absolute; - top: 5px; - right: 5px; - z-index: 10; - opacity: 0; - transition: opacity 0.2s ease; - - .scrapbook-slot-remove-btn { - background-color: rgba(255, 255, 255, 0.8); - border: 1px solid #ccc; - border-radius: 50%; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 10px; - - &:hover { - background-color: rgba(255, 0, 0, 0.1); - } - } - } - - &:hover .scrapbook-slot-controls { - opacity: 1; - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx b/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx deleted file mode 100644 index 2c8f93778..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx +++ /dev/null @@ -1,28 +0,0 @@ - -//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION -export interface SlotDefinition { - id: string; - x: number; y: number; - defaultWidth: number; - defaultHeight: number; - } - - export interface SlotContentMap { - slotId: string; - docId?: string; - } - - export interface ScrapbookConfig { - slots: SlotDefinition[]; - contents?: SlotContentMap[]; - } - - export const DEFAULT_SCRAPBOOK_CONFIG: ScrapbookConfig = { - slots: [ - { id: "slot1", x: 10, y: 10, defaultWidth: 180, defaultHeight: 120 }, - { id: "slot2", x: 200, y: 10, defaultWidth: 180, defaultHeight: 120 }, - // …etc - ], - contents: [] - }; -
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts b/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts deleted file mode 100644 index 686917d9a..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts +++ /dev/null @@ -1,25 +0,0 @@ -// ScrapbookSlotTypes.ts -export interface SlotDefinition { - id: string; - title: string; - x: number; - y: number; - defaultWidth: number; - defaultHeight: number; - } - - export interface ScrapbookConfig { - slots: SlotDefinition[]; - contents?: { slotId: string; docId: string }[]; - } - - // give it three slots by default: - export const DEFAULT_SCRAPBOOK_CONFIG: ScrapbookConfig = { - slots: [ - { id: "main", title: "Main Content", x: 20, y: 20, defaultWidth: 360, defaultHeight: 200 }, - { id: "notes", title: "Notes", x: 20, y: 240, defaultWidth: 360, defaultHeight: 160 }, - { id: "resources", title: "Resources", x: 400, y: 20, defaultWidth: 320, defaultHeight: 380 }, - ], - contents: [], - }; -
\ No newline at end of file diff --git a/src/client/views/nodes/trails/PresBox.scss b/src/client/views/nodes/trails/PresBox.scss index e24b47bd1..8875f7012 100644 --- a/src/client/views/nodes/trails/PresBox.scss +++ b/src/client/views/nodes/trails/PresBox.scss @@ -229,7 +229,7 @@ .toolbar-transition { display: flex; font-size: 10; - width: 100; + width: 100px; background-color: rgba(0, 0, 0, 0); min-width: max-content; @@ -358,7 +358,7 @@ font-weight: 200; padding: 8px; border-radius: 4px; - // height: 20; + // height: 20px; // display: flex; // margin-left: 5px; // margin-top: 5px; @@ -371,8 +371,8 @@ } .ribbon-propertyUpDown { - height: 20; - width: 20; + height: 20px; + width: 20px; margin-top: 5px; display: grid; grid-template-rows: 10px 10px; @@ -486,7 +486,7 @@ -moz-user-select: none; -ms-user-select: none; user-select: none; - margin: 0; + margin: 0px; margin-right: 3px; border-radius: 100%; height: 15px; @@ -658,9 +658,9 @@ height: 25px; color: white; width: 100%; - max-width: 120; - padding-left: 10; - padding-right: 10; + max-width: 120px; + padding-left: 10px; + padding-right: 10px; border-radius: 10px; background-color: global.$medium-gray; } @@ -683,9 +683,9 @@ height: 25px; color: global.$light-gray; width: 100%; - max-width: 120; - padding-left: 10; - padding-right: 10; + max-width: 120px; + padding-left: 10px; + padding-right: 10px; border-radius: 10px; background-color: global.$black; } @@ -745,9 +745,9 @@ .selectedList { display: block; - min-width: 50; - max-width: 120; - height: 70; + min-width: 50px; + max-width: 120px; + height: 70px; overflow-y: scroll; .selectedList-items { @@ -760,7 +760,7 @@ cursor: pointer; font-size: 10.5; font-weight: 300; - height: 20; + height: 20px; background-color: global.$medium-gray; color: white; display: flex; @@ -788,7 +788,7 @@ cursor: pointer; font-size: 10.5; font-weight: 200; - height: 20; + height: 20px; background-color: global.$white; display: inline-flex; color: global.$black; @@ -813,7 +813,7 @@ } svg.svg-inline--fa.fa-thumbtack.fa-w-12.toolbar-thumbtack { - right: 40; + right: 40px; position: absolute; transform: rotate(45deg); } @@ -850,7 +850,7 @@ background-color: global.$light-gray; border-radius: 5px; font-size: 10; - height: 25; + height: 25px; color: global.$black; padding-left: 5px; align-items: center; @@ -868,8 +868,8 @@ display: block; padding-left: 10px; padding-right: 5px; - padding-top: 3; - padding-bottom: 3; + padding-top: 3px; + padding-bottom: 3px; opacity: 0.8; } @@ -959,8 +959,8 @@ border-radius: 4px; padding-left: 7px; padding-right: 7px; - border-bottom-right-radius: 0; - border-top-right-radius: 0; + border-bottom-right-radius: 0px; + border-top-right-radius: 0px; } .presBox-button-right { @@ -976,8 +976,8 @@ border-radius: 4px; padding-left: 7px; padding-right: 7px; - border-bottom-left-radius: 0; - border-top-left-radius: 0; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; } .presBox-button-right.active { @@ -1043,8 +1043,8 @@ cursor: pointer; align-self: center; justify-self: center; - margin-top: 5; - margin-bottom: 5; + margin-top: 5px; + margin-bottom: 5px; position: relative; height: 55px; min-width: 90px; @@ -1063,7 +1063,7 @@ padding-left: 3px; margin-left: 3px; margin-right: 3px; - height: 13; + height: 13px; font-size: 12; display: flex; background-color: global.$white; @@ -1076,7 +1076,7 @@ margin-left: 3px; margin-right: 3px; font-weight: 400; - height: 13; + height: 13px; font-size: 9; display: flex; background-color: global.$white; @@ -1089,11 +1089,11 @@ padding-left: 3px; margin-left: 3px; margin-right: 3px; - height: 13; + height: 13px; font-size: 10; display: flex; background-color: global.$white; - height: 33; + height: 33px; text-align: left; font-size: 8px; } @@ -1112,7 +1112,7 @@ .presBox-viewPicker { cursor: pointer; - height: 25; + height: 25px; position: relative; display: inline-block; grid-column: 1; @@ -1184,9 +1184,9 @@ } .collectionViewBaseChrome-viewPicker { - min-width: 50; + min-width: 50px; width: 5%; - height: 25; + height: 25px; position: relative; display: inline-block; left: 8px; @@ -1210,11 +1210,11 @@ } .presBox-backward { - left: 5; + left: 5px; } .presBox-forward { - right: 5; + right: 5px; } // CSS adjusted for mobile devices @@ -1233,8 +1233,8 @@ .presBox-button { margin-top: 5%; - height: 250; - width: 300; + height: 250px; + width: 300px; font-size: 100; display: flex; align-items: center; @@ -1256,7 +1256,7 @@ } .presBox-cont .presBox-listCont { - top: 50; + top: 50px; height: calc(100% - 80px); } @@ -1269,8 +1269,8 @@ .miniPres { cursor: grab; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; opacity: 0.5; transition: all 0.4s; color: global.$white; @@ -1298,7 +1298,7 @@ .presPanel-button-text { cursor: pointer; display: flex; - height: 20; + height: 20px; width: max-content; font-family: Roboto; font-weight: 400; @@ -1327,8 +1327,8 @@ grid-template-columns: auto auto auto; justify-content: space-around; font-size: 11; - margin-left: 7; - width: 30; + margin-left: 7px; + width: 30px; height: 85%; background-color: rgba(91, 157, 221, 0.4); border-radius: 5px; @@ -1337,8 +1337,8 @@ .presPanel-button { cursor: pointer; display: flex; - height: 20; - min-width: 20; + height: 20px; + min-width: 20px; margin-left: 3px; margin-right: 3px; border-radius: 100%; @@ -1361,8 +1361,8 @@ // cursor: grab; // position: absolute; // overflow: hidden; -// right: 10; -// top: 10; +// right: 10px; +// top: 10px; // opacity: 0.1; // transition: all 0.4s; // /* border: solid 1px; */ @@ -1380,7 +1380,7 @@ // .miniPres-button-text { // cursor: pointer; // display: flex; -// height: 20; +// height: 20px; // font-weight: 400; // min-width: 100%; // border-radius: 5px; @@ -1397,8 +1397,8 @@ // grid-template-columns: auto auto auto; // justify-content: space-around; // font-size: 11; -// margin-left: 7; -// width: 30; +// margin-left: 7px; +// width: 30px; // height: 85%; // background-color: rgba(91, 157, 221, 0.4); // border-radius: 5px; @@ -1413,8 +1413,8 @@ // .miniPres-button { // cursor: pointer; // display: flex; -// height: 20; -// min-width: 20; +// height: 20px; +// min-width: 20px; // border-radius: 100%; // align-items: center; // justify-content: center; diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index 11f35b8ef..04b312ca5 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -702,7 +702,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { transTime + 10 ); } - if ((pinDataTypes?.pannable || (!pinDataTypes && (activeItem.config_viewBounds !== undefined || activeItem.config_panX !== undefined || activeItem.config_viewScale !== undefined))) && !bestTarget.isGroup) { + if ((pinDataTypes?.pannable || (!pinDataTypes && (activeItem.config_viewBounds !== undefined || activeItem.config_panX !== undefined || activeItem.config_viewScale !== undefined))) && !Doc.IsFreeformGroup(bestTarget)) { const contentBounds = Cast(activeItem.config_viewBounds, listSpec('number')); if (contentBounds) { const viewport = { panX: (contentBounds[0] + contentBounds[2]) / 2, panY: (contentBounds[1] + contentBounds[3]) / 2, width: contentBounds[2] - contentBounds[0], height: contentBounds[3] - contentBounds[1] }; @@ -1743,6 +1743,15 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { return <div />; } + setAiEffectsRef = (r: HTMLTextAreaElement | null) => + setTimeout(() => { + if (r && !r.textContent) { + r.style.height = ''; + r.style.height = r.scrollHeight + 'px'; + } + }); + + setAnimDictationRef = (r: DictationButton | null) => (this._animationDictation = r); /** * This chatbox is for getting slide effect transition suggestions from gpt and visualizing them */ @@ -1755,14 +1764,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { <ReactTextareaAutosize placeholder="Use AI to suggest effects. Leave blank for random results." className="pres-chatbox" - ref={r => { - setTimeout(() => { - if (r && !r.textContent) { - r.style.height = ''; - r.style.height = r.scrollHeight + 'px'; - } - }); - }} + ref={this.setAiEffectsRef} value={this._animationChat} onChange={e => { e.currentTarget.style.height = ''; @@ -1784,12 +1786,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { color={SnappingManager.userVariantColor} onClick={this.customizeAnimations} /> - <DictationButton - ref={r => { - this._animationDictation = r; - }} - setInput={this.setAnimationChat} - /> + <DictationButton ref={this.setAnimDictationRef} setInput={this.setAnimationChat} /> </div> <div style={{ alignItems: 'center' }}> Click a box to use the effect. @@ -1821,6 +1818,16 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { ); } + setPropertiesRef = (r: HTMLTextAreaElement | null) => + setTimeout(() => { + if (r && !r.textContent) { + r.style.height = ''; + r.style.height = r.scrollHeight + 'px'; + } + }); + + setSlideDictationRef = (r: DictationButton | null) => (this._slideDictation = r); + @computed get transitionDropdown() { const { activeItem } = this; // Retrieving spring timing properties @@ -1855,14 +1862,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { <ReactTextareaAutosize placeholder="Describe how to modify the slide properties." className="pres-chatbox" - ref={r => { - setTimeout(() => { - if (r && !r.textContent) { - r.style.height = ''; - r.style.height = r.scrollHeight + 'px'; - } - }); - }} + ref={this.setPropertiesRef} value={this._chatInput} onChange={e => { e.currentTarget.style.height = ''; @@ -1874,12 +1874,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { e.stopPropagation(); }} /> - <DictationButton - ref={r => { - this._slideDictation = r; - }} - setInput={this.setChatInput} - /> + <DictationButton ref={this.setSlideDictationRef} setInput={this.setChatInput} /> </div> <Button style={{ alignSelf: 'flex-end' }} diff --git a/src/client/views/nodes/trails/PresSlideBox.scss b/src/client/views/nodes/trails/PresSlideBox.scss index 9ac2b5a94..740bcae5f 100644 --- a/src/client/views/nodes/trails/PresSlideBox.scss +++ b/src/client/views/nodes/trails/PresSlideBox.scss @@ -117,8 +117,8 @@ $slide-active: #5b9fdd; height: 100%; position: absolute; border-radius: 3px; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 1; overflow: hidden; } @@ -209,7 +209,7 @@ $slide-active: #5b9fdd; position: absolute; /* grid-row: 3; */ /* grid-column: 1/8; */ - top: 28; + top: 28px; display: block; background: #92adb9; width: 100%; @@ -276,7 +276,7 @@ $slide-active: #5b9fdd; cursor: pointer; position: absolute; border-radius: 100px; - bottom: 0; + bottom: 0px; left: -18; z-index: 300; width: 15px; @@ -302,7 +302,7 @@ $slide-active: #5b9fdd; color: #d5dce2; position: absolute; left: -15px; - top: 1; + top: 1px; font-weight: 600; font-size: 12; } |
