diff options
Diffstat (limited to 'src')
22 files changed, 1616 insertions, 380 deletions
diff --git a/src/ClientUtils.ts b/src/ClientUtils.ts index 01eda7e98..58648c5b6 100644 --- a/src/ClientUtils.ts +++ b/src/ClientUtils.ts @@ -234,6 +234,40 @@ export namespace ClientUtils { return 'rgba(' + col.r + ',' + col.g + ',' + col.b + (col.a !== undefined ? ',' + col.a : '') + ')'; } + export function hexToHsv(hex: string): [number, number, number] { + if (!hex) return [0, 0, 0]; // Default to black if hex is not defined + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + const d = max - min; + let h: number; + const s = max === 0 ? 0 : d / max; + const v = max; + + switch (max) { + case min: + h = 0; + break; + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + default: + h = 0; + break; + } + h /= 6; + return [h, s, v]; + }; + + export function HSLtoRGB(h: number, s: number, l: number) { // Must be fractions of 1 // s /= 100; diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 8dd3fd6e2..2657bf12e 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -12,6 +12,10 @@ enum GPTCallType { DESCRIBE = 'describe', MERMAID = 'mermaid', DATA = 'data', + RUBRIC = 'rubric', + TYPE = 'type', + SUBSET = 'subset', + INFO = 'info' } type GPTCallOpts = { @@ -26,6 +30,7 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { summary: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' }, edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' }, flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled. Do not label each flashcard and do not include asterisks: ' }, + completion: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: "You are a helpful assistant. Answer the user's prompt." }, mermaid: { model: 'gpt-4-turbo', @@ -42,8 +47,8 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { sort: { model: 'gpt-4o', maxTokens: 2048, - temp: 0.5, - prompt: "I'm going to give you a list of descriptions. Each one is seperated by ====== on either side. They will vary in length, so make sure to only seperate when you see ======. Sort them into lists by shared content. MAKE SURE EACH DESCRIPTOR IS IN ONLY ONE LIST. Generate only the list with each list seperated by ====== with the elements seperated by ~~~~~~. Try to do around 4 groups, but a little more or less is ok.", + temp: 0.25, + prompt: "The user is going to give you a list of descriptions. Each one is separated by `======` on either side. Descriptions will vary in length, so make sure to only separate when you see `======`. Sort them by the user's specifications. Make sure each description is only in the list once. Each item should be separated by `======`. Immediately afterward, surrounded by `------` on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description). It is VERY important that you format it exactly as described, ensuring the proper number of `=` and `-` (6 of each) and NO commas" }, describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' }, chatcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Answer the following question as a short flashcard response. Do not include a label.' }, @@ -51,8 +56,38 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { model: 'gpt-4-turbo', maxTokens: 1024, temp: 0, - prompt: 'List unique differences between the content of the UserAnswer and Rubric. Before each difference, label it and provide any additional information the UserAnswer missed and explain it in second person without separating it into UserAnswer and Rubric content and additional information. If there are no differences, say correct', + prompt: "BRIEFLY (<50 words) describe any differences between the rubric and the user's answer answer in second person. If there are no differences, say correct", + }, + + rubric: { + model: 'gpt-4-turbo', + maxTokens: 1024, + temp: 0, + prompt: "BRIEFLY (<25 words) provide a definition for the following term. It will be used as a rubric to evaluate the user's understanding of the topic", + }, + + type: { + model: 'gpt-4-turbo', + maxTokens: 1024, + temp: 0, + prompt: "I'm going to provide you with a question. Based on the question, is the user asking you to 1. Assigns docs with tags(like star / heart etc)/labels, 2. Filter docs, 3. Provide information about a specific doc 4. Provide a specific doc based on a question/information 5. Provide general information 6. Put cards in a specific order. Answer with only the number for 2-6. For number one, provide the number (1) and the appropriate tag", }, + + subset: { + model: 'gpt-4-turbo', + maxTokens: 1024, + temp: 0, + prompt: "I'm going to give you a list of descriptions. Each one is separated by `======` on either side. Descriptions will vary in length, so make sure to only separate when you see `======`. Based on the question the user asks, provide a subset of the given descriptions that best matches the user's specifications. Make sure each description is only in the list once. Each item should be separated by `======`. Immediately afterward, surrounded by `------` on BOTH SIDES, provide some insight into your reasoning in the 2nd person (and mention nothing about the formatting details given in this description). It is VERY important that you format it exactly as described, ensuring the proper number of `=` and `-` (6 of each) and no commas" + }, + + info: { + model: 'gpt-4-turbo', + maxTokens: 1024, + temp: 0, + prompt: "Answer the user's question with a short (<100 word) response. If a particular document is selected I will provide that information (which may help with your response)" + }, + + }; let lastCall = ''; @@ -70,7 +105,7 @@ const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: a try { lastCall = inputText; - const usePrompt = prompt ? opts.prompt + prompt : opts.prompt; + const usePrompt = prompt ? prompt + opts.prompt : opts.prompt; const messages: ChatCompletionMessageParam[] = [ { role: 'system', content: usePrompt }, { role: 'user', content: inputText }, diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 89356072a..aac15059e 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -488,8 +488,7 @@ export class DocumentOptions { userColor?: STRt = new StrInfo('color associated with a Dash user (seen in header fields of shared documents)'); cardSort?: STRt = new StrInfo('way cards are sorted in deck view'); - cardSort_customField?: STRt = new StrInfo('field key used for sorting cards'); - cardSort_visibleSortGroups?: List<number>; // which sorting values are being filtered (shown) + cardSort_isDesc?: BOOLt = new BoolInfo('whether the cards are sorted ascending or descending'); } export const DocOptions = new DocumentOptions(); diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 2014c48e1..eee6be937 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -40,11 +40,12 @@ import { ColorScheme } from "./SettingsManager"; import { SnappingManager } from "./SnappingManager"; import { UndoManager } from "./UndoManager"; -interface Button { +export interface Button { // DocumentOptions fields a button can set title?: string; toolTip?: string; icon?: string; + isSystem?: boolean; btnType?: ButtonType; numBtnMin?: number; numBtnMax?: number; @@ -673,30 +674,35 @@ pie title Minerals in my tap water { title: "Time", icon:"hourglass-half", toolTip:"Sort by most recent document creation", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"time", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, { title: "Type", icon:"eye", toolTip:"Sort by document type", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"docType",funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, { title: "Color", icon:"palette", toolTip:"Sort by document color", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"color", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, + { title: "Tags", icon:"bolt", toolTip:"Sort by document's tags", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"tag", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, + { title: "Pile", icon:"layer-group", toolTip:"View the cards as a pile in the free form view !", btnType: ButtonType.ClickButton, expertMode: false, toolType:"pile", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, + { title: "Chat Popup", icon:"lightbulb", toolTip:"Toggle the chat popup's visibility!", width: 45 ,btnType: ButtonType.ToggleButton, expertMode: false, toolType:"toggle-chat", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'} }, + + { title: "Sort", toolTip: "Manage sort order / lock status", icon: "sort" , btnType: ButtonType.MultiToggleButton, toolType:"alignment", ignoreClick: true, + subMenu: [ + { title: "Ascending", toolTip: "Sort the cards in ascending order", btnType: ButtonType.ToggleButton, icon: "sort-up", toolType:"up", ignoreClick: true, scripts: {onClick: '{ return showFreeform(this.toolType, _readOnly_);}' }}, + { title: "Descending", toolTip: "Sort the cards in descending order",btnType: ButtonType.ToggleButton, icon: "sort-down",toolType:"down",ignoreClick: true, scripts: {onClick: '{ return showFreeform(this.toolType, _readOnly_);}'} }, + ]}, ] } - static labelTools(): Button[] { - return [ - { title: "AI", icon:"robot", toolTip:"Add AI labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"chat", funcs: {hidden:`showFreeform ("chat", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "AIs", icon:"AI Sort", toolTip:"Filter AI labels", subMenu: this.cardGroupTools("chat"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("chat", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, - { title: "Like", icon:"heart", toolTip:"Add Like labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"like", funcs: {hidden:`showFreeform ("like", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "Likes", icon:"Likes", toolTip:"Filter likes", width: 10, subMenu: this.cardGroupTools("heart"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("like", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, - { title: "Star", icon:"star", toolTip:"Add Star labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"star", funcs: {hidden:`showFreeform ("star", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "Stars", icon:"Stars", toolTip:"Filter stars", width: 80, subMenu: this.cardGroupTools("star"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("star", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, - { title: "Idea", icon:"satellite", toolTip:"Add Idea labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"idea", funcs: {hidden:`showFreeform ("idea", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "Ideas", icon:"Ideas", toolTip:"Filter ideas", width: 80, subMenu: this.cardGroupTools("satellite"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("idea", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, - ] - } - static cardGroupTools(icon: string): Button[] { - return [ - { title: "1", icon, toolTip:"Click to toggle visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"1", funcs: {hidden:`!cardHasLabel(this.toolType)`}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "2", icon, toolTip:"Click to toggle visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"2", funcs: {hidden:`!cardHasLabel(this.toolType)`}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "3", icon, toolTip:"Click to toggle visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"3", funcs: {hidden:`!cardHasLabel(this.toolType)`}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "4", icon, toolTip:"Click to toggle visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"4", funcs: {hidden:`!cardHasLabel(this.toolType)`}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "5", icon, toolTip:"Click to toggle visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"5", funcs: {hidden:`!cardHasLabel(this.toolType)`}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "6", icon, toolTip:"Click to toggle visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"6", funcs: {hidden:`!cardHasLabel(this.toolType)`}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - { title: "7", icon, toolTip:"Click to toggle visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"7", funcs: {hidden:`!cardHasLabel(this.toolType)`}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, - ] + + static tagGroupTools(): Button[] { + if (!Doc.UserDoc().activeDashboard) { + Doc.UserDoc().myFilterHotKeyTitles = new List<string>(['Star', 'Heart', 'Bolt', 'Cloud']); + + ['Star', 'Heart', 'Bolt', 'Cloud'].forEach(key => { + Doc.UserDoc()[key] = key.toLowerCase(); + }); +} + // hack: if there's no dashboard, create default filters. otherwise, just make sure that the Options button is preserved + return (Doc.UserDoc().activeDashboard ? [] : [ + { title: "Star", isSystem: false, icon: "star", toolTip:"Click to toggle the star group's visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"star", funcs: {}, scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}'}}, + { title: "Heart", isSystem: false,icon: "heart", toolTip:"Click to toggle the heart group's visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"heart", funcs: {}, scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}'}}, + { title: "Bolt", isSystem: false,icon: "bolt", toolTip:"Click to toggle the bolt group's visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"bolt", funcs: {}, scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}'}}, + { title: "Cloud", isSystem: false,icon: "cloud", toolTip:"Click to toggle the cloud group's visibility", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"cloud", funcs: {}, scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}'}}, + ]).concat([ + { title: "Options", isSystem: true,icon: "gear", toolTip:"Click to customize your filter panel", btnType: ButtonType.ClickButton, expertMode: false, toolType:"opts", funcs: {}, scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}'}} + ]) } static viewTools(): Button[] { return [ @@ -799,25 +805,35 @@ pie title Minerals in my tap water { title: "Back", icon: "chevron-left", toolTip: "Prev Animation Frame", btnType: ButtonType.ClickButton, expertMode: true, toolType:CollectionViewType.Freeform, funcs: {hidden: '!SelectedDocType(this.toolType, this.expertMode)'}, width: 30, scripts: { onClick: 'prevKeyFrame(_readOnly_)'}}, { title: "Num", icon:"", toolTip: "Frame # (click to toggle edit mode)",btnType: ButtonType.TextButton, expertMode: true, toolType:CollectionViewType.Freeform, funcs: {hidden: '!SelectedDocType(this.toolType, this.expertMode)', buttonText: 'selectedDocs()?.lastElement()?.currentFrame?.toString()'}, width: 20, scripts: { onClick: '{ return curKeyFrame(_readOnly_);}'}}, { title: "Fwd", icon: "chevron-right", toolTip: "Next Animation Frame", btnType: ButtonType.ClickButton, expertMode: true, toolType:CollectionViewType.Freeform, funcs: {hidden: '!SelectedDocType(this.toolType, this.expertMode)'}, width: 30, scripts: { onClick: 'nextKeyFrame(_readOnly_)'}}, + + + { title: "Text", icon: "Text", toolTip: "Text functions", subMenu: CurrentUserUtils.textTools(), expertMode: false, toolType:DocumentType.RTF, funcs: { linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available { title: "Ink", icon: "Ink", toolTip: "Ink functions", subMenu: CurrentUserUtils.inkTools(), expertMode: false, toolType:DocumentType.INK, funcs: {hidden: `IsExploreMode()`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`}, scripts: { onClick: 'setInkToolDefaults()'} }, // Always available { title: "Doc", icon: "Doc", toolTip: "Freeform Doc tools", subMenu: CurrentUserUtils.freeTools(), expertMode: false, toolType:CollectionViewType.Freeform, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode, true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available { title: "View", icon: "View", toolTip: "View tools", subMenu: CurrentUserUtils.viewTools(), expertMode: false, toolType:CollectionViewType.Freeform, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available { title: "Stack", icon: "View", toolTip: "Stacking tools", subMenu: CurrentUserUtils.stackTools(), expertMode: false, toolType:CollectionViewType.Stacking, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available - { title: "Card", icon: "Sort", toolTip: "Card sort", subMenu: CurrentUserUtils.cardTools(), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available - { title: "Label", icon: "Label", toolTip: "Assign card labels", subMenu: CurrentUserUtils.labelTools(), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available + + { title: "Card", icon: "Card", toolTip: "Card View Tools", subMenu: CurrentUserUtils.cardTools(), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available + // { title: "Create", icon: "Create", toolTip: "Assign card labels", subMenu: CurrentUserUtils.labelTools(), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Always available { title: "Web", icon: "Web", toolTip: "Web functions", subMenu: CurrentUserUtils.webTools(), expertMode: false, toolType:DocumentType.WEB, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Only when Web is selected { title: "Video", icon: "Video", toolTip: "Video functions", subMenu: CurrentUserUtils.videoTools(), expertMode: false, toolType:DocumentType.VID, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Only when video is selected { title: "Image", icon: "Image", toolTip: "Image functions", subMenu: CurrentUserUtils.imageTools(), expertMode: false, toolType:DocumentType.IMG, funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Only when image is selected { title: "Schema", icon: "Schema",linearBtnWidth:58,toolTip: "Schema functions",subMenu: CurrentUserUtils.schemaTools(),expertMode: false,toolType:CollectionViewType.Schema,funcs: {hidden: `!SelectedDocType(this.toolType, this.expertMode)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} }, // Only when Schema is selected + { title: "Filter", icon:"=", toolTip:"Filter cards by tags", btnType: ButtonType.MultiToggleButton, width: 150, ignoreClick: true,toolType:DocumentType.COL, + subMenu: this.tagGroupTools(), funcs: {hidden: '!SelectedDocType(this.toolType, this.expertMode)'}, + + }, ]; + } /// initializes a context menu button for the top bar context menu static setupContextMenuButton(params:Button, btnDoc?:Doc, btnContainer?:Doc) { const reqdOpts:DocumentOptions = { - ...OmitKeys(params, ["scripts", "funcs", "subMenu"]).omit as {[key:string]: string|undefined}, - color: Colors.WHITE, isSystem: true, + isSystem: true, + ...OmitKeys(params, ["scripts", "funcs", "subMenu"]).omit, + color: Colors.WHITE, _nativeWidth: params.width ?? 30, _width: params.width ?? 30, _height: 30, _nativeHeight: 30, linearBtnWidth: params.linearBtnWidth, toolType: params.toolType, expertMode: params.expertMode, @@ -986,6 +1002,7 @@ pie title Minerals in my tap water doc.filterDocCount = 0; doc.treeView_FreezeChildren = "remove|add"; doc.activePage = doc.activeDashboard === undefined ? 'home': doc.activePage; + this.setupLinkDocs(doc, linkDatabaseId); this.setupSharedDocs(doc, sharingDocumentId); // sets up the right sidebar collection for mobile upload documents and sharing this.setupDefaultIconTemplates(doc); // creates a set of icon templates triggered by the document deoration icon @@ -1119,4 +1136,4 @@ ScriptingGlobals.add(function importDocument() { return CurrentUserUtils.import // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function setInkToolDefaults() { Doc.ActiveTool = InkTool.None; }); // eslint-disable-next-line prefer-arrow-callback -ScriptingGlobals.add(function getSharingDoc() { return Doc.SharingDoc() }); +ScriptingGlobals.add(function getSharingDoc() { return Doc.SharingDoc() });
\ No newline at end of file diff --git a/src/client/views/DocumentButtonBar.scss b/src/client/views/DocumentButtonBar.scss index 11614d627..ede277aae 100644 --- a/src/client/views/DocumentButtonBar.scss +++ b/src/client/views/DocumentButtonBar.scss @@ -29,6 +29,11 @@ $linkGap: 3px; background: black; height: 20px; align-items: center; + + .tags { + width: 40px; + + } } .documentButtonBar-followTypes { width: 20px; @@ -153,6 +158,10 @@ $linkGap: 3px; &:hover { background-color: $black; + + .documentButtonBar-pinTypes { + display: flex; + } } } diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index f14fd033b..ccde1de80 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { FaEdit } from 'react-icons/fa'; import { returnFalse, returnTrue, setupMoveUpEvents, simulateMouseClick } from '../../ClientUtils'; import { emptyFunction } from '../../Utils'; -import { Doc, DocListCast } from '../../fields/Doc'; +import { Doc } from '../../fields/Doc'; import { Cast, DocCast } from '../../fields/Types'; import { DocUtils, IsFollowLinkScript } from '../documents/DocUtils'; import { CalendarManager } from '../util/CalendarManager'; @@ -264,25 +264,82 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( } @computed + get calendarButton() { + const targetDoc = this.view0?.Document; + return !targetDoc ? null : ( + <Tooltip title={<div className="dash-calendar-button">Open calendar menu</div>}> + <div + className="documentButtonBar-icon" + style={{ color: 'white' }} + onClick={() => { + CalendarManager.Instance.open(this.view0, targetDoc); + }}> + <FontAwesomeIcon className="documentdecorations-icon" icon={faCalendarDays as IconLookup} /> + </div> + </Tooltip> + ); + } + + /** + * Allows for both the keywords and the icon tags to be shown using a quasi- multitoggle + */ + @computed get keywordButton() { - return !DocumentView.Selected().length ? null : ( + const targetDoc = this.view0?.Document; + + const metaBtn = (name: string, icon: IconProp) => { + const tooltip = `Toggle ${name}`; + return ( + <Tooltip title={<div className="dash-tooltip">{tooltip}</div>}> + <div className="documentButtonBar-pinIcon"> + <FontAwesomeIcon + className="documentdecorations-icon" + style={{ width: 20 }} + key={icon.toString()} + size="sm" + icon={icon} + onClick={e => { + if (name === 'tags'){ + (targetDoc && (targetDoc[DocData].showIconTags = !targetDoc[DocData].showIconTags)) + } else { + (targetDoc && (targetDoc[DocData].showLabels = !targetDoc[DocData].showLabels)) + } + + + + + }} + /> + </div> + </Tooltip> + ); + }; + + + + + return !targetDoc ? null : ( + <div className='documentButtonBar-icon'> + <div className="documentButtonBar-pinTypes" style = {{width: '40px'}}> + {metaBtn('tags', 'star')} + {metaBtn("keywords", 'id-card')} + </div> + <Tooltip title={<div className="dash-keyword-button">Open keyword menu</div>}> <div className="documentButtonBar-icon" style={{ color: 'white' }} - onClick={undoable(e => { - const showing = DocumentView.Selected().some(dv => dv.layoutDoc._layout_showTags); - DocumentView.Selected().forEach(dv => { - dv.layoutDoc._layout_showTags = !showing; - if (e.shiftKey) - DocListCast(dv.Document[Doc.LayoutFieldKey(dv.Document) + '_annotations']).forEach(doc => { - if (doc.face) doc.hidden = showing; - }); - }); - }, 'show Doc tags')}> + onClick={() => { + // targetDoc[DocData].showIconTags = !targetDoc[DocData].showIconTags; + }} + > + + <FontAwesomeIcon className="documentdecorations-icon" icon="tag" /> </div> </Tooltip> + </div> + ); } @@ -455,10 +512,11 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( {!DocumentView.Selected().some(v => v.allLinks.length) ? null : <div className="documentButtonBar-button">{this.followLinkButton}</div>} <div className="documentButtonBar-button">{this.pinButton}</div> <div className="documentButtonBar-button">{this.recordButton}</div> + <div className="documentButtonBar-button">{this.calendarButton}</div> <div className="documentButtonBar-button">{this.keywordButton}</div> {!Doc.UserDoc().documentLinksButton_fullMenu ? null : <div className="documentButtonBar-button">{this.shareButton}</div>} <div className="documentButtonBar-button">{this.menuButton}</div> </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 5e7908725..37b1f04e7 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -708,6 +708,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora const maxDist = Math.min((this.Bounds.r - this.Bounds.x) / 2, (this.Bounds.b - this.Bounds.y) / 2); const radiusHandle = (borderRadius / docMax) * maxDist; const radiusHandleLocation = Math.min(radiusHandle, maxDist); + const sharingMenu = Doc.IsSharingEnabled && docShareMode ? ( @@ -770,6 +771,8 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora const centery = hideTitle ? 0 : this._titleHeight; const transformOrigin = `${50}% calc(50% + ${centery / 2}px)`; const freeformDoc = DocumentView.Selected().some(v => CollectionFreeFormDocumentView.from(v)); + + return ( <div className="documentDecorations" style={{ display: this._showNothing && !freeformDoc ? 'none' : undefined }}> <div diff --git a/src/client/views/FilterPanel.scss b/src/client/views/FilterPanel.scss index d6d2956aa..508b1ee1f 100644 --- a/src/client/views/FilterPanel.scss +++ b/src/client/views/FilterPanel.scss @@ -1,3 +1,4 @@ + .filterBox-flyout { display: block; text-align: left; @@ -228,6 +229,97 @@ vertical-align: middle; } +.filterHotKey-button { + pointer-events: auto; + width: 100%; //width: 25px; + border-radius: 5px; + margin-top: 8px; + border-color: #d3d3d3; + border-style: solid; + border-width: thin; + transition: all 0.3s ease-out; + display: flex; + flex-direction: row; + padding: 5px; + + + &:hover{ + border-color: #e9e9e9; + background-color: #6d6c6c + } + + .hotKey-icon, .hotKey-close{ + background-color: transparent; + border-radius: 10%; + padding: 5px; + + + &:hover{ + background-color: #616060; + } + } + + .hotKey-close{ + right: 30px; + position: fixed; + padding-top: 10px; + +} + + .hotkey-title{ + top: 6px; + position: relative; + cursor: text; + + } + + .hotkey-title-input{ + background-color: transparent; + border: none; + border-color: transparent; + outline: none; + cursor: text; + + } +} + +.hotKeyButtons { + position: relative; + width: 100%; + +} + +.hotKey-icon-button { + + background-color: transparent; + + +} + +.icon-panel { + position: absolute; + z-index: 10000; + border-color: black; + border-style: solid; + border-width: medium; + border-radius: 10%; + background-color: #323232; + + .icon-panel-button{ + background-color: #323232; + border-radius: 10%; + + + &:hover{ + background-color:#7a7878 + } + } + + + +} + + // .sliderBox-outerDiv { // width: 30%;// width: calc(100% - 14px); // 14px accounts for handles that are at the max value of the slider that would extend outside the box // height: 40; // height: 100%; diff --git a/src/client/views/FilterPanel.tsx b/src/client/views/FilterPanel.tsx index 2f6d1fbaa..f3f447e22 100644 --- a/src/client/views/FilterPanel.tsx +++ b/src/client/views/FilterPanel.tsx @@ -11,12 +11,24 @@ import { List } from '../../fields/List'; import { RichTextField } from '../../fields/RichTextField'; import { SearchUtil } from '../util/SearchUtil'; import { SnappingManager } from '../util/SnappingManager'; -import { undoable } from '../util/UndoManager'; +import { undoable, undoBatch } from '../util/UndoManager'; import { FieldsDropdown } from './FieldsDropdown'; import './FilterPanel.scss'; import { DocumentView } from './nodes/DocumentView'; import { Handle, Tick, TooltipRail, Track } from './nodes/SliderBox-components'; import { ObservableReactComponent } from './ObservableReactComponent'; +import { Button } from '../util/CurrentUserUtils'; +import { ButtonType } from './nodes/FontIconBox/FontIconBox'; +import { DocCast } from '../../fields/Types'; +import { CurrentUserUtils } from '../util/CurrentUserUtils'; +import { StrCast } from '../../fields/Types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { DocData } from '../../fields/DocSymbols'; +import { Tooltip } from '@mui/material'; +import { useLocalObservable } from 'mobx-react'; +import { useRef } from 'react'; +import { useEffect } from 'react'; + interface filterProps { Document: Doc; @@ -25,10 +37,13 @@ interface filterProps { @observer export class FilterPanel extends ObservableReactComponent<filterProps> { @observable _selectedFacetHeaders = new Set<string>(); + public static Instance: FilterPanel; + constructor(props: filterProps) { super(props); makeObservable(this); + FilterPanel.Instance = this; } /** @@ -211,12 +226,67 @@ export class FilterPanel extends ObservableReactComponent<filterProps> { return nonNumbers / facetValues.length > 0.1 ? facetValues.sort() : facetValues.sort((n1: string, n2: string) => Number(n1) - Number(n2)); }; + /** + * Allows users to add a filter hotkey to the properties panel. Will also update the multitoggle at the top menu and the + * icontags tht are displayed on the documents themselves + * @param hotKey tite of the new hotkey + */ + addHotkey = (hotKey: string) => { + const buttons = DocCast(Doc.UserDoc().myContextMenuBtns); + const filter = DocCast(buttons.Filter); + + const newKey: Button = { + title: hotKey, + icon: 'bolt', + toolTip: `Click to toggle the ${hotKey}'s group's visibility`, + btnType: ButtonType.ToggleButton, + expertMode: false, + toolType: 'bolt', + funcs: {}, + scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}' }, + }; + + const currHotKeys = StrListCast(Doc.UserDoc().myFilterHotKeyTitles) + + Doc.UserDoc().myFilterHotKeyTitles = new List<string>(currHotKeys.concat(hotKey)) + + Doc.UserDoc()[hotKey] = 'bolt' + + const newBtn = CurrentUserUtils.setupContextMenuBtn(newKey, filter); + newBtn.isSystem = newBtn[DocData].isSystem = undefined; + + const subDocs = DocListCast(filter.data) + const opts = subDocs[subDocs.length-1] + Doc.AddDocToList(filter, 'data', newBtn, opts, true); + } + + + /** + * Renders the newly formed hotkey icon buttons + * @returns the buttons to be rendered + */ + hotKeyButtons = () => { + const selected = DocumentView.SelectedDocs().lastElement(); + const hotKeys = StrListCast(Doc.UserDoc().myFilterHotKeyTitles); + + // Selecting a button should make it so that the icon on the top filter panel becomes said icon + const buttons = hotKeys.map((hotKey, i) => ( + <Tooltip key={hotKey} title={<div className="dash-tooltip">Click to customize this hotkey's icon</div>}> + <HotKeyIconButton hotKey={hotKey} selected = {selected}/> + </Tooltip> + )); + + return buttons; + } + + // @observable iconPanelMap: Map<string, number> = new Map(); + render() { return ( <div className="filterBox-treeView"> <div className="filterBox-select"> <div style={{ width: '100%' }}> - <FieldsDropdown Document={this.Document} selectFunc={this.facetClick} showPlaceholder placeholder="add a filter" addedFields={['acl_Guest', LinkedTo]} /> + <FieldsDropdown Document={this.Document} selectFunc={this.facetClick} showPlaceholder placeholder="add a filter" addedFields={['acl_Guest', LinkedTo, 'Star', 'Heart', 'Bolt', 'Cloud']} /> </div> {/* THE FOLLOWING CODE SHOULD BE DEVELOPER FOR BOOLEAN EXPRESSION (AND / OR) */} {/* <div className="filterBox-select-bool"> @@ -277,6 +347,15 @@ export class FilterPanel extends ObservableReactComponent<filterProps> { ) )} </div> + <div> + <div className="filterBox-select"> + <div style={{ width: '100%' }}> + <FieldsDropdown Document={this.Document} selectFunc={this.addHotkey} showPlaceholder placeholder="add a hotkey" addedFields={['acl_Guest', LinkedTo]} /> + </div> + </div> + </div> + + <div>{this.hotKeyButtons()}</div> </div> ); } @@ -385,3 +464,182 @@ export class FilterPanel extends ObservableReactComponent<filterProps> { return undefined; } } + + + +interface HotKeyButtonProps { + hotKey: string; + selected?: Doc +} + +/** + * Renders the buttons that correspond to each icon tag in the properties view. Allows users to change the icon, + * title, and delete. + */ +const HotKeyIconButton: React.FC<HotKeyButtonProps> = observer(({ hotKey, selected}) => { + const state = useLocalObservable(() => ({ + isActive: false, + isEditing: false, + myHotKey: hotKey, + + toggleActive() { + this.isActive = !this.isActive; + }, + deactivate() { + this.isActive = false; + }, + startEditing() { + this.isEditing = true; + }, + stopEditing() { + this.isEditing = false; + }, + setHotKey(newHotKey: string) { + this.myHotKey = newHotKey; + } + })); + + const panelRef = useRef<HTMLDivElement>(null); + const inputRef = useRef<HTMLInputElement>(null); + + const handleClick = () => { + state.toggleActive(); + }; + + const hotKeys = StrListCast(Doc.UserDoc().myFilterHotKeyTitles) + const buttons = DocCast(Doc.UserDoc().myContextMenuBtns); + const filter = DocCast(buttons.Filter); + + /** + * The doc of the button in the context menu that corresponds to the current hotkey + * @returns + */ + const myHotKeyDoc = () => { + const hotKeyDocs = DocListCast(filter.data) + return hotKeyDocs.filter(k => StrCast(k.title) === hotKey)[0] + + } + + /** + * Removes a hotkey from list + */ + const removeHotKey = () => { + Doc.RemoveDocFromList(filter, 'data', myHotKeyDoc()); + + } + + /** + * Deselects if the user clicks outside the button + * @param event + */ + const handleClickOutside = (event: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(event.target as Node)) { + state.deactivate(); + if (state.isEditing) { + state.stopEditing(); + + updateFromInput() + } + } + }; + + /** + * Updates the list of hotkeys based on the users input. replaces the old title with the new one and then assigns this new + * hotkey with the current icon + */ + const updateFromInput = undoable (() => { + const myDoc = myHotKeyDoc() + Doc.UserDoc().myFilterHotKeyTitles = new List<string>(hotKeys.map(k => k === hotKey ? state.myHotKey : k)); + Doc.UserDoc()[state.myHotKey] = StrCast(Doc.UserDoc()[hotKey]) + Doc.UserDoc()[hotKey] = '' + myDoc.title = state.myHotKey + myDoc.toolTip = `Click to toggle the ${state.myHotKey}'s group's visibility` + }, '') + + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const iconOpts = ['star', 'heart', 'bolt', 'satellite', 'palette', 'robot', 'lightbulb', 'highlighter', 'book', 'chalkboard' ]; + + /** + * Panel of icons the user can choose from to represent their tag + */ + const iconPanel = iconOpts.map((icon, i) => ( + <button key={i} onClick={undoable((e: React.MouseEvent) => { + e.stopPropagation; + Doc.UserDoc()[hotKey] = icon; + myHotKeyDoc().icon = icon + }, '')} className='icon-panel-button'> + <FontAwesomeIcon icon={icon as any} color = {SnappingManager.userColor}/> + </button> + )); + + /** + * Actually renders the buttons + */ + + return ( + <div className={`filterHotKey-button`} + onClick={(e) => { + e.stopPropagation(); + state.startEditing(); + setTimeout(() => inputRef.current?.focus(), 0); + }} + > + <div className={`hotKey-icon-button ${state.isActive ? 'active' : ''}`} ref={panelRef}> + <Tooltip title={<div className="dash-tooltip">Click to customize this hotkey's icon</div>}> + <button + type="button" + className='hotKey-icon' + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + handleClick(); + }} + > + <FontAwesomeIcon icon={Doc.UserDoc()[hotKey] as any} size="2xl" color={SnappingManager.userColor}/> + </button> + </Tooltip> + {state.isActive && ( + <div className="icon-panel"> + {iconPanel} + </div> + )} + </div> + {state.isEditing ? ( + <input + ref={inputRef} + type="text" + value={state.myHotKey.toUpperCase()} + onChange={(e) => state.setHotKey(e.target.value)} + onBlur={() => { + state.stopEditing(); + updateFromInput() + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + state.stopEditing(); + updateFromInput() + + } + }} + className='hotkey-title-input' + /> + ) : ( + <p className='hotkey-title'>{hotKey.toUpperCase()}</p> + )} + <button className='hotKey-close' onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + Doc.UserDoc().myFilterHotKeyTitles = new List<string>(hotKeys.filter(k => k !== hotKey)); + removeHotKey() + + }}> + <FontAwesomeIcon icon={'x' as any} color={SnappingManager.userColor}/> + </button> + </div> + ); +})
\ No newline at end of file diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 393abea53..74a788d94 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -87,7 +87,7 @@ export class MainView extends ObservableReactComponent<object> { @observable private _windowWidth: number = 0; @observable private _windowHeight: number = 0; - @observable private _dashUIWidth: number = 0; // width of entire main dashboard region including left menu buttons and properties panel (but not including the dashboard selector button row) + @observable _dashUIWidth: number = 0; // width of entire main dashboard region including left menu buttons and properties panel (but not including the dashboard selector button row) @observable private _dashUIHeight: number = 0; // height of entire main dashboard region including top menu buttons @observable private _panelContent: string = 'none'; @observable private _sidebarContent: Doc = Doc.MyLeftSidebarPanel; @@ -546,6 +546,10 @@ export class MainView extends ObservableReactComponent<object> { fa.faRobot, fa.faSatellite, fa.faStar, + fa.faCloud, + fa.faBolt, + fa.faLightbulb, + fa.faX ] ); } diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index daa8e1720..e940ba6f9 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -54,7 +54,7 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps private _widthUndo?: UndoManager.Batch; // eslint-disable-next-line no-use-before-define - public static Instance: PropertiesView | undefined; + public static Instance: PropertiesView; constructor(props: PropertiesViewProps) { super(props); makeObservable(this); diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 262f888fb..54698ab2d 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -25,6 +25,8 @@ import { DocumentView, DocumentViewProps } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; import { StyleProp } from './StyleProp'; import './StyleProvider.scss'; +import { IconTagBox } from './nodes/IconTagBox'; +import { DocData } from '../../fields/DocSymbols'; function toggleLockedPosition(doc: Doc) { UndoManager.RunInBatch(() => Doc.toggleLockedPosition(doc), 'toggleBackground'); @@ -366,6 +368,11 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & ); }; const tags = () => props?.DocumentView?.() ? <TagsView Views={[props.DocumentView()]}/> : null; + + const iconTags = () => { + if (doc && doc![DocData].showIconTags) + {return (<IconTagBox doc= {doc}></IconTagBox>)} + } return ( <> {paint()} @@ -373,6 +380,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & {filter()} {audio()} {tags()} + {iconTags()} </> ); } diff --git a/src/client/views/collections/CollectionCardDeckView.scss b/src/client/views/collections/CollectionCardDeckView.scss index cc797d0bd..5ccc3d9a8 100644 --- a/src/client/views/collections/CollectionCardDeckView.scss +++ b/src/client/views/collections/CollectionCardDeckView.scss @@ -12,7 +12,6 @@ height: 35px; border-radius: 50%; background-color: $dark-gray; - // border-color: $medium-blue; margin: 5px; // transform: translateY(-50px); } } @@ -20,7 +19,6 @@ .card-wrapper { display: grid; grid-template-columns: repeat(10, 1fr); - // width: 100%; transform-origin: top left; position: absolute; @@ -31,39 +29,17 @@ transition: transform 0.3s cubic-bezier(0.455, 0.03, 0.515, 0.955); } -.card-button-container { - display: flex; - padding: 3px; - // width: 300px; - background-color: rgb(218, 218, 218); /* Background color of the container */ - border-radius: 50px; /* Rounds the corners of the container */ - transform: translateY(75px); - // box-shadow: 0 4px 8px rgba(0,0,0,0.1); /* Optional: Adds shadow for depth */ - align-items: center; /* Centers buttons vertically */ - justify-content: start; /* Centers buttons horizontally */ -} - -// button:hover { -// transform: translateY(-50px); -// } +.no-card-span{ + position: relative; + width: fit-content; + text-align: center; + font-size: 65px; + -// .card-wrapper::after { -// content: ""; -// width: 100%; /* Forces wrapping */ -// } -// .card-wrapper > .card-item:nth-child(10n)::after { -// content: ""; -// width: 100%; /* Forces wrapping after every 10th item */ -// } +} -// .card-row{ -// display: flex; -// position: absolute; -// align-items: center; -// transition: transform 0.3s cubic-bezier(0.455, 0.03, 0.515, 0.955); -// } .card-item-inactive, .card-item-active, @@ -74,11 +50,11 @@ flex-direction: column; } + .card-item-inactive { opacity: 0.5; } .card-item-active { - position: absolute; z-index: 100; } diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx index 28a769896..bb932b8b2 100644 --- a/src/client/views/collections/CollectionCardDeckView.tsx +++ b/src/client/views/collections/CollectionCardDeckView.tsx @@ -1,9 +1,9 @@ import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { ClientUtils, DashColor, returnFalse, returnZero } from '../../../ClientUtils'; -import { numberRange } from '../../../Utils'; -import { Doc, NumListCast } from '../../../fields/Doc'; +import { ClientUtils, returnFalse, returnZero } from '../../../ClientUtils'; +import { emptyFunction } from '../../../Utils'; +import { Doc, StrListCast } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { BoolCast, Cast, DateCast, NumCast, RTFCast, ScriptCast, StrCast } from '../../../fields/Types'; @@ -18,15 +18,30 @@ import { StyleProp } from '../StyleProp'; import { DocumentView } from '../nodes/DocumentView'; import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup'; import './CollectionCardDeckView.scss'; -import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; +import { CollectionSubView } from './CollectionSubView'; +import { dropActionType } from '../../util/DropActionTypes'; +import { DocCast } from '../../../fields/Types'; +import { SelectionManager } from '../../util/SelectionManager'; + + enum cardSortings { Time = 'time', Type = 'type', Color = 'color', Custom = 'custom', + Chat = 'chat', + Tag = 'tag', None = '', } + +/** + * New view type specifically for studying more dynamically. Allows you to reorder docs however you see fit, easily + * sort and filter using presets, and customize your experience with chat gpt. + * + * This file contains code as to how the docs are to be rendered (there place geographically and also in regards to sorting), + * and callback functions for the gpt popup + */ @observer export class CollectionCardView extends CollectionSubView() { private _dropDisposer?: DragManager.DragDropDisposer; @@ -35,15 +50,14 @@ export class CollectionCardView extends CollectionSubView() { private _textToDoc = new Map<string, Doc>(); @observable _forceChildXf = false; - @observable _isLoading = false; @observable _hoveredNodeIndex = -1; @observable _docRefs = new ObservableMap<Doc, DocumentView>(); @observable _maxRowCount = 10; + @observable _docDraggedIndex: number = -1; + @observable _isACardBeingDragged: boolean = false; + @observable overIndex: number = -1; - static getButtonGroup(groupFieldKey: 'chat' | 'star' | 'idea' | 'like', doc: Doc): number | undefined { - return Cast(doc[groupFieldKey], 'number', null); - } - + static imageUrlToBase64 = async (imageUrl: string): Promise<string> => { try { const response = await fetch(imageUrl); @@ -61,22 +75,46 @@ export class CollectionCardView extends CollectionSubView() { } }; - protected createDashEventsTarget = (ele: HTMLDivElement | null) => { - this._dropDisposer?.(); - if (ele) { - this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc); - } - }; - - constructor(props: SubCollectionViewProps) { + constructor(props: any) { super(props); makeObservable(this); + this.setRegenerateCallback(); } + /** + * Callback to ensure gpt's text versions of the child docs are updated + */ + setRegenerateCallback = () => { + GPTPopup.Instance.setRegenerateCallback(this.childPairStringListAndUpdateSortDesc); + } + + /** + * update's gpt's doc-text list and initializes callbacks + */ + @action + childPairStringListAndUpdateSortDesc = async () => { + const sortDesc = await this.childPairStringList(); // Await the promise to get the string result + GPTPopup.Instance.setSortDesc(sortDesc.join()); + GPTPopup.Instance.onSortComplete = (sortResult: string, questionType: string, tag?: string) => this.processGptOutput(sortResult, questionType, tag); + GPTPopup.Instance.onQuizRandom = () => this.quizMode(); + }; + componentDidMount(): void { + this.Document.childFilters_boolean = 'OR'; + this.childDocsWithoutLinks.forEach(c => { + c[DocData].showIconTags = true; + }); + + // Reaction to cardSort changes this._disposers.sort = reaction( - () => ({ cardSort: this.cardSort, field: this.cardSort_customField }), - ({ cardSort, field }) => (cardSort === cardSortings.Custom && field === 'chat' ? this.openChatPopup() : GPTPopup.Instance.setVisible(false)) + () => GPTPopup.Instance.visible, + isVis => { + if (isVis) { + this.openChatPopup(); + } else { + this.Document.cardSort = (this.cardSort === cardSortings.Chat) ? '' : this.Document.cardSort; + } + } ); } @@ -92,6 +130,7 @@ export class CollectionCardView extends CollectionSubView() { @computed get cardSort() { return StrCast(this.Document.cardSort) as cardSortings; } + /** * how much to scale down the contents of the view so that everything will fit */ @@ -100,43 +139,31 @@ export class CollectionCardView extends CollectionSubView() { return (this._childDocumentWidth * length) / this._props.PanelWidth(); } - @computed get translateWrapperX() { - let translate = 0; - - if (this.inactiveDocs().length !== this.childDocsWithoutLinks.length && this.inactiveDocs().length < 10) { - translate += this.panelWidth() / 2; - } - return translate; - } - /** * The child documents to be rendered-- either all of them except the Links or the docs in the currently active * custom group */ @computed get childDocsWithoutLinks() { - const regularDocs = this.childDocs.filter(l => l.type !== DocumentType.LINK); - const activeGroups = NumListCast(this.Document.cardSort_visibleSortGroups); - - if (activeGroups.length > 0 && this.cardSort === cardSortings.Custom) { - return regularDocs.filter(doc => { - // Get the group number for the current index - const groupNumber = CollectionCardView.getButtonGroup(this.cardSort_customField, doc); - // Check if the group number is in the active groups - return groupNumber !== undefined && activeGroups.includes(groupNumber); - }); - } + return this.childDocs.filter(l => l.type !== DocumentType.LINK); + } - // Default return for non-custom cardSort or other cases, filtering out links - return regularDocs; + /** + * When in quiz mode, randomly selects a document + */ + quizMode = () => { + const randomIndex = Math.floor(Math.random() * this.childDocs.length); + SelectionManager.DeselectAll(); + DocumentView.SelectView(DocumentView.getDocumentView(this.childDocs[randomIndex]), false); } /** - * Determines the order in which the cards will be rendered depending on the current sort type + * Number of rows of cards to be rendered */ - @computed get sortedDocs() { - return this.sort(this.childDocsWithoutLinks, this.cardSort, BoolCast(this.layoutDoc.sortDesc)); + @computed get numRows() { + return Math.ceil(this.sortedDocs.length / 10); } + @action setHoveredNodeIndex = (index: number) => { if (!DocumentView.SelectedDocs().includes(this.childDocs[index])) { @@ -170,6 +197,8 @@ export class CollectionCardView extends CollectionSubView() { * @returns */ rotate = (amCards: number, index: number) => { + if (amCards == 1) return 0; + const possRotate = -30 + index * (30 / ((amCards - (amCards % 2)) / 2)); const stepMag = Math.abs(-30 + (amCards / 2 - 1) * (30 / ((amCards - (amCards % 2)) / 2))); @@ -205,27 +234,120 @@ export class CollectionCardView extends CollectionSubView() { }; /** - * Translates the selected node to the middle fo the screen - * @param index - * @returns + * When dragging a card, determines the index the card should be set to if dropped + * @param mouseX mouse's x location + * @param mouseY mouses' y location + * @returns the card's new index */ - translateSelected = (index: number): number => { - // if (this.isSelected(index)) { - const middleOfPanel = this._props.PanelWidth() / 2; - const scaledNodeWidth = this.panelWidth() * 1.25; + findCardDropIndex = (mouseX: number, mouseY: number) => { + const amCardsTotal = this.sortedDocs.length; + let index = 0; + const cardWidth = amCardsTotal < this._maxRowCount ? this._props.PanelWidth() / amCardsTotal : this._props.PanelWidth() / this._maxRowCount; - // Calculate the position of the node's left edge before scaling - const nodeLeftEdge = index * this.panelWidth(); - // Find the center of the node after scaling - const scaledNodeCenter = nodeLeftEdge + scaledNodeWidth / 2; + // Calculate the adjusted X position accounting for the initial offset + let adjustedX = mouseX; - // Calculate the translation needed to align the scaled node's center with the panel's center - const translation = middleOfPanel - scaledNodeCenter - scaledNodeWidth - scaledNodeWidth / 4; + const amRows = Math.ceil(amCardsTotal / this._maxRowCount); + const rowHeight = this._props.PanelHeight() / amRows; + const currRow = Math.floor((mouseY - 100) / rowHeight); //rows start at 0 + + if (adjustedX < 0) { + return 0; // Before the first column + } - return translation; + if (amCardsTotal < this._maxRowCount) { + index = Math.floor(adjustedX / cardWidth); + } else if (currRow != amRows - 1) { + index = Math.floor(adjustedX / cardWidth) + currRow * this._maxRowCount; + } else { + const rowAmCards = amCardsTotal - currRow * this._maxRowCount; + const offset = ((this._maxRowCount - rowAmCards) / 2) * cardWidth; + adjustedX = mouseX - offset; + + index = Math.floor(adjustedX / cardWidth) + currRow * this._maxRowCount; + } + return index; }; /** + * Checks to see if a card is being dragged and calls the appropriate methods if so + * @param e the current pointer event + */ + + @action + onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { + if (DragManager.docsBeingDragged.length != 0) { + this._isACardBeingDragged = true; + + const newIndex = this.findCardDropIndex(e.clientX, e.clientY); + + if (newIndex !== this._docDraggedIndex && newIndex != -1) { + this._docDraggedIndex = newIndex; + } + } + }; + + /** + * Resets all the doc dragging vairables once a card is dropped + * @param e + * @param de drop event + * @returns true if a card has been dropped, falls if not + */ + onInternalDrop = undoable((e: Event, de: DragManager.DropEvent) => { + if (de.complete.docDragData) { + this._isACardBeingDragged = false; + this._docDraggedIndex = -1; + e.stopPropagation(); + return true; + } + return false; + }, '' ); + + get sortedDocs() { + return this.sort(this.childDocsWithoutLinks, this.cardSort, BoolCast(this.Document.cardSort_isDesc), this._docDraggedIndex); + } + + + /** + * Used to determine how to sort cards based on tags. The lestmost tags are given lower values while cards to the right are + * given higher values. Decimals are used to determine placement for cards with multiple tags + * @param doc the doc whose value is being determined + * @returns its value based on its tags + */ + + tagValue = (doc: Doc) => { + const keys = StrListCast(Doc.UserDoc().myFilterHotKeyTitles); + + const isTagActive = (buttonID: number) => { + return BoolCast(doc[StrCast(Doc.UserDoc()[keys[buttonID]])]); + }; + + let base = ''; + let fraction = ''; + + for (let i = 0; i < keys.length; i++) { + if (isTagActive(i)) { + if (base === '') { + base = i.toString(); // First active tag becomes the base + } else { + fraction += i.toString(); // Subsequent active tags become part of the fraction + } + } + } + + // If no tag was active, return 0 by default + if (base === '') { + return 0; + } + + // Construct the final number by appending the fraction if it exists + const numberString = fraction ? `${base}.${fraction}` : base; + + // Convert the result to a number and return + return Number(numberString); + } + + /** * Called in the sortedDocsType method. Compares the cards' value in regards to the desired sort type-- earlier cards are move to the * front, latter cards to the back * @param docs @@ -233,28 +355,39 @@ export class CollectionCardView extends CollectionSubView() { * @param isDesc * @returns */ - sort = (docs: Doc[], sortType: cardSortings, isDesc: boolean) => { - if (sortType === cardSortings.None) return docs; + @action sort = (docs: Doc[], sortType: cardSortings, isDesc: boolean, dragIndex: number) => { docs.sort((docA, docB) => { const [typeA, typeB] = (() => { switch (sortType) { - case cardSortings.Time: - return [DateCast(docA.author_date)?.date ?? Date.now(), - DateCast(docB.author_date)?.date ?? Date.now()]; - case cardSortings.Color: - return [DashColor(StrCast(docA.backgroundColor)).hsv().toString(), // If docA.type is undefined, use an empty string - DashColor(StrCast(docB.backgroundColor)).hsv().toString()]; // If docB.type is undefined, use an empty string - case cardSortings.Custom: - return [CollectionCardView.getButtonGroup(this.cardSort_customField, docA)??0, - CollectionCardView.getButtonGroup(this.cardSort_customField, docB)??0]; - default: return [StrCast(docA.type), // If docA.type is undefined, use an empty string - StrCast(docB.type)]; // If docB.type is undefined, use an empty string - } // prettier-ignore + case cardSortings.Time: + return [DateCast(docA.author_date)?.date ?? Date.now(), DateCast(docB.author_date)?.date ?? Date.now()]; + case cardSortings.Color: + return [ClientUtils.hexToHsv(StrCast(docA.backgroundColor)), ClientUtils.hexToHsv(StrCast(docB.backgroundColor))]; + case cardSortings.Tag: + return [this.tagValue(docA) ?? 9999, this.tagValue(docB) ?? 9999]; + case cardSortings.Chat: + return [NumCast(docA.chatIndex) ?? 9999, NumCast(docB.chatIndex) ?? 9999]; + + default: + return [StrCast(docA.type), StrCast(docB.type)]; + } })(); const out = typeA < typeB ? -1 : typeA > typeB ? 1 : 0; - return isDesc ? -out : out; // Reverse the sort order if descending is true + + if (isDesc) { + return out; + } + + return -out; }); + if (dragIndex != -1) { + const draggedDoc = DragManager.docsBeingDragged[0]; + const originalIndex = docs.findIndex(doc => doc === draggedDoc); + + docs.splice(originalIndex, 1); + docs.splice(dragIndex, 0, draggedDoc); + } return docs; }; @@ -273,10 +406,12 @@ export class CollectionCardView extends CollectionSubView() { LayoutTemplate={this._props.childLayoutTemplate} LayoutTemplateString={this._props.childLayoutString} ScreenToLocalTransform={screenToLocalTransform} // makes sure the box wrapper thing is in the right spot - isContentActive={this.isChildContentActive} + isContentActive={emptyFunction} isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive} PanelWidth={this.panelWidth} PanelHeight={this.panelHeight(doc)} + dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType} + dontHideOnDrag /> ); @@ -286,11 +421,11 @@ export class CollectionCardView extends CollectionSubView() { * @returns */ overflowAmCardsCalc = (index: number) => { - if (this.inactiveDocs().length < this._maxRowCount) { - return this.inactiveDocs().length; + if (this.sortedDocs.length < this._maxRowCount) { + return this.sortedDocs.length; } // 13 - 3 = 10 - const totalCards = this.inactiveDocs().length; + const totalCards = this.sortedDocs.length; // if 9 or less if (index < totalCards - (totalCards % 10)) { return this._maxRowCount; @@ -323,22 +458,17 @@ export class CollectionCardView extends CollectionSubView() { * @returns */ calculateTranslateY = (isHovered: boolean, isSelected: boolean, realIndex: number, amCards: number, calcRowIndex: number) => { - if (isSelected) return 50 * this.fitContentScale; - const trans = isHovered ? this.translateHover(realIndex) : 0; + const rowHeight = (this._props.PanelHeight() * this.fitContentScale) / this.numRows; + const rowIndex = Math.trunc(realIndex / this._maxRowCount); + const rowToCenterShift = this.numRows / 2 - rowIndex; + if (isSelected) return rowToCenterShift * rowHeight - rowHeight / 2; + if (amCards == 1) return 50 * this.fitContentScale; + // const trans = isHovered ? this.translateHover(realIndex) : 0; + const trans = 0; return trans + this.translateY(amCards, calcRowIndex, realIndex); }; /** - * Toggles the buttons between on and off when creating custom sort groupings/changing those created by gpt - * @param childPairIndex - * @param buttonID - * @param doc - */ - toggleButton = undoable((buttonID: number, doc: Doc) => { - this.cardSort_customField && (doc[this.cardSort_customField] = buttonID); - }, 'toggle custom button'); - - /** * A list of the text content of all the child docs. RTF documents will have just their text and pdf documents will have the first 50 words. * Image documents are converted to bse64 and gpt generates a description for them. all other documents use their title. This string is * inputted into the gpt prompt to sort everything together @@ -355,6 +485,7 @@ export class CollectionCardView extends CollectionSubView() { }; const docTextPromises = this.childDocsWithoutLinks.map(async doc => { const docText = (await docToText(doc)) ?? ''; + doc['gptInputText'] = docText; this._textToDoc.set(docText.replace(/\n/g, ' ').trim(), doc); return `======${docText.replace(/\n/g, ' ').trim()}======`; }); @@ -377,78 +508,110 @@ export class CollectionCardView extends CollectionSubView() { image[DocData].description = response.trim(); return response; // Return the response from gptImageLabel } catch (error) { - console.log('bad things have happened'); + console.log(error); } return ''; }; /** - * Converts the gpt output into a hashmap that can be used for sorting. lists are seperated by ==== while elements within the list are seperated by ~~~~~~ + * Processes gpt's output depending on the type of question the user asked. Converts gpt's string output to + * usable code * @param gptOutput */ - processGptOutput = (gptOutput: string) => { + @action + + processGptOutput = undoable((gptOutput: string, questionType: string, tag?: string) => { // Split the string into individual list items const listItems = gptOutput.split('======').filter(item => item.trim() !== ''); + + if (questionType === '2' || questionType === '4') { + this.childDocs.forEach(d => { + d['chatFilter'] = false; + }); + } + + if (questionType ==='6'){ + this.Document.cardSort = 'chat' + } + + listItems.forEach((item, index) => { - // Split the item by '~~~~~~' to get all descriptors - const parts = item.split('~~~~~~').map(part => part.trim()); - - parts.forEach(part => { - // Find the corresponding Doc in the textToDoc map - const doc = this._textToDoc.get(part); - if (doc) { - doc.chat = index; + const normalizedItem = item.trim(); + // find the corresponding Doc in the textToDoc map + const doc = this._textToDoc.get(normalizedItem); + + if (doc) { + switch (questionType) { + case '6': + doc.chatIndex = index; + break; + case '1': + const allHotKeys = StrListCast(Doc.UserDoc().myFilterHotKeyTitles); + + let myTag = ''; + + if (tag) { + for (let i = 0; i < allHotKeys.length; i++) { + if (tag.includes(allHotKeys[i])) { + myTag = StrCast(Doc.UserDoc()[allHotKeys[i]]); + break; + } else if (tag.includes(StrCast(Doc.UserDoc()[allHotKeys[i]]))) { + myTag = StrCast(Doc.UserDoc()[allHotKeys[i]]); + break; + } + } + + if (myTag != '') { + doc[myTag] = true; + } + } + break; + case '2': + case '4': + doc['chatFilter'] = true; + Doc.setDocFilter(DocCast(this.Document.embedContainer), 'chatFilter', true, 'match'); + break; } - }); + } else { + console.warn(`No matching document found for item: ${normalizedItem}`); + } }); - }; + }, ''); + + /** * Opens up the chat popup and starts the process for smart sorting. */ openChatPopup = async () => { GPTPopup.Instance.setVisible(true); - GPTPopup.Instance.setMode(GPTPopupMode.SORT); - const sortDesc = await this.childPairStringList(); // Await the promise to get the string result + GPTPopup.Instance.setMode(GPTPopupMode.CARD); GPTPopup.Instance.setCardsDoneLoading(true); // Set dataDoneLoading to true after data is loaded - GPTPopup.Instance.setSortDesc(sortDesc.join()); - GPTPopup.Instance.onSortComplete = (sortResult: string) => this.processGptOutput(sortResult); + await this.childPairStringListAndUpdateSortDesc() }; /** - * Renders the buttons to customize sorting depending on which group the card belongs to and the amount of total groups - * @param childPairIndex - * @param doc - * @returns - */ - renderButtons = (doc: Doc, cardSort: cardSortings) => { - if (cardSort !== cardSortings.Custom) return ''; - const amButtons = Math.max(4, this.childDocs?.reduce((set, d) => this.cardSort_customField && set.add(NumCast(d[this.cardSort_customField])), new Set<number>()).size ?? 0); - const activeButtonIndex = CollectionCardView.getButtonGroup(this.cardSort_customField, doc); - const totalWidth = amButtons * 35 + amButtons * 2 * 5 + 6; - return ( - <div className="card-button-container" style={{ width: `${totalWidth}px` }}> - {numberRange(amButtons).map(i => ( - <button - key={i} - type="button" - style={{ backgroundColor: activeButtonIndex === i ? '#4476f7' : '#323232' }} // - onClick={() => this.toggleButton(i, doc)} - /> - ))} - </div> - ); - }; - /** * Actually renders all the cards */ renderCards = () => { const anySelected = this.childDocs.some(doc => DocumentView.SelectedDocs().includes(doc)); + const isEmpty = this.childDocsWithoutLinks.length === 0; + const isDesc = BoolCast(this.Document.cardSort_isDesc); + + if (isEmpty) { + return ( + <span className="no-card-span" style={{ width: ` ${this._props.PanelWidth()}px`, height: ` ${this._props.PanelHeight()}px` }}> + Sorry ! There are no cards in this group + </span> + ); + } + // Map sorted documents to their rendered components return this.sortedDocs.map((doc, index) => { - const realIndex = this.sortedDocs.filter(sortDoc => !DocumentView.SelectedDocs().includes(sortDoc)).indexOf(doc); + const realIndex = this.sortedDocs.indexOf(doc); const calcRowIndex = this.overflowIndexCalc(realIndex); const amCards = this.overflowAmCardsCalc(realIndex); const isSelected = DocumentView.SelectedDocs().includes(doc); + const isDragging = DragManager.docsBeingDragged.includes(doc); const childScreenToLocal = () => { this._forceChildXf; @@ -459,6 +622,12 @@ export class CollectionCardView extends CollectionSubView() { .scale(1 / scale).rotate(!isSelected ? -this.rotate(amCards, calcRowIndex) : 0); // prettier-ignore }; + const translateIfSelected = () => { + const indexInRow = index % this._maxRowCount; + const rowIndex = Math.trunc(index / this._maxRowCount); + const rowCenterIndex = Math.min(this._maxRowCount, this.sortedDocs.length - rowIndex * this._maxRowCount) / 2; + return (rowCenterIndex - indexInRow) * 100 - 50; + }; return ( <div key={doc[Id]} @@ -471,29 +640,34 @@ export class CollectionCardView extends CollectionSubView() { SnappingManager.SetIsResizing(undefined); this._forceChildXf = !this._forceChildXf; }), - 700 + 900 ); }} style={{ width: this.panelWidth(), - height: 'max-content', // this.panelHeight(childPair.layout)(), + height: 'max-content', transform: `translateY(${this.calculateTranslateY(this._hoveredNodeIndex === index, isSelected, realIndex, amCards, calcRowIndex)}px) - translateX(${isSelected ? this.translateSelected(calcRowIndex) : this.translateOverflowX(realIndex, amCards)}px) + translateX(calc(${(isSelected ? translateIfSelected() : 0) + '% + ' + this.translateOverflowX(realIndex, amCards) + 'px'})) rotate(${!isSelected ? this.rotate(amCards, calcRowIndex) : 0}deg) - scale(${isSelected ? 1.25 : 1})`, + scale(${isSelected ? 2 : this._hoveredNodeIndex === index ? 1.05 : 1})`, }} onMouseEnter={() => this.setHoveredNodeIndex(index)}> {this.displayDoc(doc, childScreenToLocal)} - {this.renderButtons(doc, this.cardSort)} </div> ); }); }; + render() { + const isEmpty = this.childDocsWithoutLinks.length === 0; + const transformValue = `scale(${1 / this.fitContentScale})`; + const heightValue = `${100 * this.fitContentScale}%`; + return ( <div + onPointerMove={e => this.onPointerMove(e)} className="collectionCardView-outer" - ref={this.createDashEventsTarget} + ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)} style={{ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string, color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string, @@ -501,8 +675,9 @@ export class CollectionCardView extends CollectionSubView() { <div className="card-wrapper" style={{ - transform: ` scale(${1 / this.fitContentScale}) translateX(${this.translateWrapperX}px)`, - height: `${100 * this.fitContentScale}%`, + ...(!isEmpty && { transform: transformValue }), + ...(!isEmpty && { height: heightValue }), + gridAutoRows: `${100 / this.numRows}%`, }} onMouseLeave={() => this.setHoveredNodeIndex(-1)}> {this.renderCards()} diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx index 5d71177c3..ad868fd1e 100644 --- a/src/client/views/collections/CollectionCarouselView.tsx +++ b/src/client/views/collections/CollectionCarouselView.tsx @@ -29,7 +29,7 @@ enum practiceVal { export class CollectionCarouselView extends CollectionSubView() { private _dropDisposer?: DragManager.DragDropDisposer; get practiceField() { return this.fieldKey + "_practice"; } // prettier-ignore - get starField() { return this.fieldKey + "_star"; } // prettier-ignore + get starField() { return "star"; } // prettier-ignore _fadeTimer: NodeJS.Timeout | undefined; _resetter: IReactionDisposer | undefined; diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts index 934ed6c3e..65bd524fe 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -2,10 +2,9 @@ import { Colors } from 'browndash-components'; import { action, runInAction } from 'mobx'; import { aggregateBounds } from '../../../Utils'; -import { Doc, DocListCast, FieldType, NumListCast, Opt } from '../../../fields/Doc'; +import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { InkTool } from '../../../fields/InkField'; -import { List } from '../../../fields/List'; import { BoolCast, Cast, NumCast, StrCast } from '../../../fields/Types'; import { WebField } from '../../../fields/URLField'; import { Gestures } from '../../../pen-gestures/GestureTypes'; @@ -36,9 +35,16 @@ import { ImageBox } from '../nodes/ImageBox'; import { VideoBox } from '../nodes/VideoBox'; import { WebBox } from '../nodes/WebBox'; import { RichTextMenu } from '../nodes/formattedText/RichTextMenu'; - +import { NumListCast, StrListCast } from '../../../fields/Doc'; +import { List } from '../../../fields/List'; +import { CollectionViewType } from '../../documents/DocumentTypes'; // import { InkTranscription } from '../InkTranscription'; - +import { Docs } from '../../documents/Documents'; +import { CollectionSubView } from '../collections/CollectionSubView'; +import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup'; +import { PropertiesView } from '../PropertiesView'; +import { MainView } from '../MainView'; +import { SnappingManager } from '../../util/SnappingManager'; // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function IsNoneSelected() { return DocumentView.Selected().length <= 0; @@ -135,20 +141,25 @@ ScriptingGlobals.add(function toggleOverlay(checkResult?: boolean) { // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function showFreeform(attr: 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'viewAll' | 'fitOnce', checkResult?: boolean, persist?: boolean) { const selected = DocumentView.SelectedDocs().lastElement(); + + function isAttrFiltered(attr: string) { + return StrListCast(selected._childFilters).some(filter => filter.includes(attr)); + } + // prettier-ignore - const map: Map<'flashcards' | 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'arrange' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'links' | 'like' | 'star' | 'idea' | 'chat' | '1' | '2' | '3' | '4', + const map: Map<'flashcards' | 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'arrange' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'chat' | 'up' | 'down' | 'pile' | 'toggle-chat' | 'tag', { waitForRender?: boolean; checkResult: (doc: Doc) => boolean; setDoc: (doc: Doc, dv: DocumentView) => void; }> = new Map([ ['grid', { - checkResult: (doc:Doc) => BoolCast(doc?._freeform_backgroundGrid, false), - setDoc: (doc:Doc) => { doc._freeform_backgroundGrid = !doc._freeform_backgroundGrid; }, + checkResult: (doc: Doc) => BoolCast(doc?._freeform_backgroundGrid, false), + setDoc: (doc: Doc) => { doc._freeform_backgroundGrid = !doc._freeform_backgroundGrid; }, }], ['snaplines', { - checkResult: (doc:Doc) => BoolCast(doc?._freeform_snapLines, false), - setDoc: (doc:Doc) => { doc._freeform_snapLines = !doc._freeform_snapLines; }, + checkResult: (doc: Doc) => BoolCast(doc?._freeform_snapLines, false), + setDoc: (doc: Doc) => { doc._freeform_snapLines = !doc._freeform_snapLines; }, }], ['viewAll', { checkResult: (doc: Doc) => BoolCast(doc?._freeform_fitContentsToBox, false), @@ -168,8 +179,8 @@ ScriptingGlobals.add(function showFreeform(attr: 'hcenter' | 'vcenter' | 'grid' }], ['clusters', { waitForRender: true, // flags that undo batch should terminate after a re-render giving the script the chance to fire - checkResult: (doc:Doc) => BoolCast(doc?._freeform_useClusters, false), - setDoc: (doc:Doc) => { doc._freeform_useClusters = !doc._freeform_useClusters; }, + checkResult: (doc: Doc) => BoolCast(doc?._freeform_useClusters, false), + setDoc: (doc: Doc) => { doc._freeform_useClusters = !doc._freeform_useClusters; }, }], ['flashcards', { checkResult: (doc: Doc) => BoolCast(Doc.UserDoc().defaultToFlashcards, false), @@ -177,111 +188,118 @@ ScriptingGlobals.add(function showFreeform(attr: 'hcenter' | 'vcenter' | 'grid' }], ['time', { checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "time", - setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort = "time", + setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort === "time" ? doc.cardSort = '' : doc.cardSort = 'time', }], ['docType', { checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "type", - setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort = "type", + setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort === "type" ? doc.cardSort = '' : doc.cardSort = 'type', }], ['color', { checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "color", - setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort = "color", + setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort === "color" ? doc.cardSort = '' : doc.cardSort = 'color', }], - ['links', { - checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "links", - setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort = "links", + ['tag', { + checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "tag", + setDoc: (doc: Doc, dv: DocumentView) => doc.cardSort === "tag" ? doc.cardSort = '' : doc.cardSort = 'tag', }], - ['like', { - checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "custom" && StrCast(doc?.cardSort_customField) === "like", + ['up', { + checkResult: (doc: Doc) => BoolCast(!doc?.cardSort_isDesc), setDoc: (doc: Doc, dv: DocumentView) => { - doc.cardSort = "custom"; - doc.cardSort_customField = "like"; - doc.cardSort_visibleSortGroups = new List<number>(); - } + doc.cardSort_isDesc = false; + }, }], - ['star', { - checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "custom" && StrCast(doc?.cardSort_customField) === "star", + ['down', { + checkResult: (doc: Doc) => BoolCast(doc?.cardSort_isDesc), setDoc: (doc: Doc, dv: DocumentView) => { - doc.cardSort = "custom"; - doc.cardSort_customField = "star"; - doc.cardSort_visibleSortGroups = new List<number>(); - } + doc.cardSort_isDesc = true; + }, }], - ['idea', { - checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "custom" && StrCast(doc?.cardSort_customField) === "idea", + ['toggle-chat', { + checkResult: (doc: Doc) => GPTPopup.Instance.visible, setDoc: (doc: Doc, dv: DocumentView) => { - doc.cardSort = "custom"; - doc.cardSort_customField = "idea"; - doc.cardSort_visibleSortGroups = new List<number>(); - } + if (GPTPopup.Instance.visible){ + doc.cardSort = '' + GPTPopup.Instance.setVisible(false); + + } else { + GPTPopup.Instance.setVisible(true); + GPTPopup.Instance.setMode(GPTPopupMode.CARD); + GPTPopup.Instance.setCardsDoneLoading(true); + + } + + + }, }], - ['chat', { - checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "custom" && StrCast(doc?.cardSort_customField) === "chat", + ['pile', { + checkResult: (doc: Doc) => doc._type_collection == CollectionViewType.Freeform, setDoc: (doc: Doc, dv: DocumentView) => { - doc.cardSort = "custom"; - doc.cardSort_customField = "chat"; - doc.cardSort_visibleSortGroups = new List<number>(); + doc._type_collection = CollectionViewType.Freeform; + const newCol = Docs.Create.CarouselDocument(DocListCast(doc[Doc.LayoutFieldKey(doc)]), { + _width: 250, + _height: 200, + _layout_fitWidth: false, + _layout_autoHeight: true, + }); + + + const iconMap: { [key: number]: any } = { + 0: 'star', + 1: 'heart', + 2: 'cloud', + 3: 'bolt' + }; + + for (let i=0; i<4; i++){ + if (isAttrFiltered(iconMap[i])){ + newCol[iconMap[i]] = true + } + } + + newCol && dv.ComponentView?.addDocument?.(newCol); + DocumentView.showDocument(newCol, { willZoomCentered: true }) + }, }], ]); - for (let i = 0; i < 8; i++) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - map.set((i + 1 + '') as any, { - checkResult: (doc: Doc) => NumListCast(doc?.cardSort_visibleSortGroups).includes(i), - setDoc: (doc: Doc, dv: DocumentView) => { - const list = NumListCast(doc.cardSort_visibleSortGroups); - doc.cardSort_visibleSortGroups = new List<number>(list.includes(i) ? list.filter(d => d !== i) : [...list, i]); - }, - }); - } if (checkResult) { return map.get(attr)?.checkResult(selected); } + const batch = map.get(attr)?.waitForRender ? UndoManager.StartBatch('set freeform attribute') : { end: () => {} }; DocumentView.Selected().map(dv => map.get(attr)?.setDoc(dv.layoutDoc, dv)); setTimeout(() => batch.end(), 100); return undefined; }); -ScriptingGlobals.add(function cardHasLabel(label: string) { +/** + * Applies a filter to the selected document (or, if the settins button is pressed, opens the filter panel) + */ +ScriptingGlobals.add(function handleTags(value?: any, checkResult?: boolean) { const selected = DocumentView.SelectedDocs().lastElement(); - const labelNum = Number(label) - 1; - return labelNum < 4 || (selected && DocListCast(selected[Doc.LayoutFieldKey(selected)]).some(doc => doc[StrCast(selected.cardSort_customField)] == labelNum)); -}, ''); - -// ScriptingGlobals.add(function setCardSortAttr(attr: 'time' | 'docType' | 'color', value: any, checkResult?: boolean) { -// // const editorView = RichTextMenu.Instance?.TextView?.EditorView; -// const selected = SelectionManager.Docs.lastElement(); -// // prettier-ignore -// const map: Map<'time' | 'docType' | 'color', { waitForRender?: boolean, checkResult: (doc:Doc) => any; setDoc: (doc:Doc, dv:DocumentView) => void;}> = new Map([ -// ['time', { -// checkResult: (doc:Doc) => StrCast(doc?.cardSort), -// setDoc: (doc:Doc,dv:DocumentView) => doc.cardSort = "time", -// }], -// ['docType', { -// checkResult: (doc:Doc) => StrCast(doc?.cardSort), -// setDoc: (doc:Doc,dv:DocumentView) => doc.cardSort = "type", -// }], -// ['color', { -// checkResult: (doc:Doc) => StrCast(doc?.cardSort), -// setDoc: (doc:Doc,dv:DocumentView) => doc.cardSort = "color", -// }], -// // ['custom', { -// // checkResult: () => RichTextMenu.Instance.textAlign, -// // setDoc: () => value && editorView?.state ? RichTextMenu.Instance.align(editorView, editorView.dispatch, value):(Doc.UserDoc().textAlign = value), -// // }] -// // , -// ]); - -// if (checkResult) { -// return map.get(attr)?.checkResult(selected); -// } - -// console.log('hey') -// SelectionManager.Views.map(dv => map.get(attr)?.setDoc(dv.layoutDoc, dv)); -// console.log('success') -// }); + + function isAttrFiltered(attr: string) { + return StrListCast(selected._childFilters).some(filter => filter.includes(attr)); + } + + if (checkResult) { + return value=== 'opts' ? PropertiesView.Instance?.openFilters : isAttrFiltered(value) + } + + if (value != 'opts'){ + isAttrFiltered(value) ? Doc.setDocFilter(selected, value, true, 'remove') : Doc.setDocFilter(selected, value, true, 'match'); + } + else { + SnappingManager.PropertiesWidth < 5 && SnappingManager.SetPropertiesWidth(0); + SnappingManager.SetPropertiesWidth(MainView.Instance.propertiesWidth() < 15 ? Math.min(MainView.Instance._dashUIWidth - 50, 250) : 0); + PropertiesView.Instance?.CloseAll() + PropertiesView.Instance.openFilters = true + } + + + return undefined; + }, ''); // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function setFontAttr(attr: 'font' | 'fontColor' | 'highlight' | 'fontSize' | 'alignment', value: string | number, checkResult?: boolean) { @@ -316,6 +334,7 @@ ScriptingGlobals.add(function setFontAttr(attr: 'font' | 'fontColor' | 'highligh ]); if (checkResult) { + // console.log(map.get(attr)?.checkResult() + "font check result") return map.get(attr)?.checkResult(); } map.get(attr)?.setDoc?.(); diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.tsx b/src/client/views/nodes/FontIconBox/FontIconBox.tsx index 7a09ad9e2..0fe112634 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBox.tsx +++ b/src/client/views/nodes/FontIconBox/FontIconBox.tsx @@ -192,7 +192,7 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() { } else { text = script?.script.run({ this: this.Document, value: '', _readOnly_: true }).result as string; // text = StrCast((RichTextMenu.Instance?.TextView?.EditorView ? RichTextMenu.Instance : Doc.UserDoc()).fontFamily); - getStyle = (val: string) => ({ fontFamily: val }); + // getStyle = (val: string) => ({ fontFamily: val }); } // Get items to place into the list diff --git a/src/client/views/nodes/IconTagBox.scss b/src/client/views/nodes/IconTagBox.scss new file mode 100644 index 000000000..211a961c1 --- /dev/null +++ b/src/client/views/nodes/IconTagBox.scss @@ -0,0 +1,24 @@ +@import '../global/globalCssVariables.module.scss'; + +.card-button-container { + display: flex; + padding: 3px; + position: absolute; + pointer-events: none; + background-color: rgb(218, 218, 218); + border-radius: 50px; + transform: translateY(25px); + align-items: center; + justify-content: start; + + button { + pointer-events: auto; + transform: translateY(-7.5px); + width: 30px; + height: 30px; + border-radius: 50%; + background-color: $dark-gray; + margin: 5px; + background-color: transparent; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/IconTagBox.tsx b/src/client/views/nodes/IconTagBox.tsx new file mode 100644 index 000000000..370ce51d1 --- /dev/null +++ b/src/client/views/nodes/IconTagBox.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { computed } from "mobx"; +import { ObservableReactComponent } from "../ObservableReactComponent"; +import { NumCast } from "../../../fields/Types"; +import { Doc } from "../../../fields/Doc"; +import { numberRange } from "../../../Utils"; +import { Tooltip } from "@mui/material"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { undoable } from "../../util/UndoManager"; +import { BoolCast } from "../../../fields/Types"; +import { DocCast } from "../../../fields/Types"; +import './IconTagBox.scss'; +import {DocData } from '../../../fields/DocSymbols'; +import { StrListCast } from "../../../fields/Doc"; +import { StrCast } from "../../../fields/Types"; +import { CollectionViewType } from "../../documents/DocumentTypes"; +import { SnappingManager } from "../../util/SnappingManager"; +import { MainView } from "../MainView"; +import { PropertiesView } from "../PropertiesView"; + + +export interface IconTagProps { + doc: Doc; +} + +/** + * Renders the icon tags that rest under the document. The icons rendered are determined by the values of + * each icon in the userdoc. + */ +@observer +export class IconTagBox extends ObservableReactComponent<IconTagProps> { + @computed + get currentScale() { + return NumCast((this._props.doc.embedContainer as Doc)?._freeform_scale, 1); + + } + + constructor(props: any) { + super(props); + } + + componentDidUpdate(prevProps: Readonly<IconTagProps>): void { + this._props.doc[DocData].tagHeight = 36*this.currentScale; + } + + + /** + * Renders the buttons to customize sorting depending on which group the card belongs to and the amount of total groups + * @param doc + * @param cardSort + * @returns + */ + renderButtons = (doc: Doc): JSX.Element | null => { + const amButtons = (StrListCast(Doc.UserDoc().myFilterHotKeyTitles).length) + 1 + + const keys = StrListCast(Doc.UserDoc().myFilterHotKeyTitles) + + const totalWidth = (amButtons -1) * 35 + (amButtons -1) * 2 * 5 + 6; + + const iconMap = (buttonID: number) => { + return StrCast(Doc.UserDoc()[keys[buttonID]]) + + }; + + const isCard = DocCast(this._props.doc.embedContainer).type_collection === CollectionViewType.Card + + + + return ( + <div + className="card-button-container" + style={{ + transformOrigin: 'top left', + transform: `scale(${ isCard ? 2 : 0.6 / this.currentScale}) + translateY(${doc[DocData].showLabels ? ((NumCast(doc[DocData].keywordHeight)*(1-this.currentScale))) : 0}px) + `, + width: `${totalWidth}px`, + fontSize: '50px' + }} + > + {numberRange(amButtons-1).map(i => ( + <Tooltip key={i} title={<div className="dash-tooltip">Click to add/remove this card from the {iconMap(i)} group</div>}> + <button key = {i} type="button" onClick={() => this.toggleButton(doc, iconMap(i) )}> + {this.getButtonIcon(doc, iconMap(i))} + </button> + </Tooltip> + ))} + </div> + ); + }; + + /** + * Opens the filter panel in the properties menu + */ + + openHotKeyMenu = () => { + SnappingManager.PropertiesWidth < 5 && SnappingManager.SetPropertiesWidth(0); + SnappingManager.SetPropertiesWidth(MainView.Instance.propertiesWidth() < 15 ? Math.min(MainView.Instance._dashUIWidth - 50, 250) : 0); + + PropertiesView.Instance.CloseAll() + PropertiesView.Instance.openFilters = true + } + + /** + * Toggles the buttons between on and off when creating custom sort groupings/changing those created by gpt + * @param childPairIndex + * @param buttonID + * @param doc + */ + toggleButton = undoable((doc: Doc, icon: string) => { + BoolCast(doc[icon]) ? doc[icon] = false : doc[icon] = true + + }, 'toggle card tag'); + + + /** + * Determines whether or not the given icon is active depending on the doc's data + * @param doc + * @param icon + * @returns + */ + getButtonIcon = (doc: Doc, icon: any): JSX.Element => { + const isActive = doc[icon] + const color = isActive ? '#4476f7' : '#323232'; + + return <FontAwesomeIcon icon={icon} style={{ color , height: '30px', width: '30px'}} />; + }; + + render (){ + return ( + <> + {this.renderButtons(this._props.doc)} + + </> + ) + + } + +} + + diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 03585a8b7..f5f758ad2 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -133,7 +133,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { _layout_autoHeight: true, }); - this.addToCollection?.(newCol); + this.addToCollection?.(newCol); //this._props.addDocument(newCol) }; pointerDown = (e: React.PointerEvent) => { diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.scss b/src/client/views/pdf/GPTPopup/GPTPopup.scss index 6d8793f82..0247dc10c 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.scss +++ b/src/client/views/pdf/GPTPopup/GPTPopup.scss @@ -7,10 +7,13 @@ $highlightedText: #82e0ff; .summary-box { position: fixed; - bottom: 10px; - right: 10px; + top: 115px; + left: 75px; width: 250px; + height: 200px; min-height: 200px; + min-width: 180px; + border-radius: 16px; padding: 16px; padding-bottom: 0; @@ -21,6 +24,18 @@ $highlightedText: #82e0ff; background-color: #ffffff; box-shadow: 0 2px 5px #7474748d; color: $textgrey; + resize: both; /* Allows resizing */ + overflow: auto; + + .resize-handle { + width: 10px; + height: 10px; + background: #ccc; + position: absolute; + right: 0; + bottom: 0; + cursor: se-resize; + } .summary-heading { display: flex; @@ -51,25 +66,76 @@ $highlightedText: #82e0ff; .content-wrapper { padding-top: 10px; min-height: 50px; - max-height: 150px; + // max-height: 150px; overflow-y: auto; + height: 100% } .btns-wrapper-gpt { - height: 50px; + height: 100%; display: flex; justify-content: center; align-items: center; - transform: translateY(30px); + flex-direction: column; + + .inputWrapper{ + display: flex; + justify-content: center; + align-items: center; + height: 60px; + position: absolute; + bottom: 0; + width: 100%; + background-color: white; + + } .searchBox-input{ - transform: translateY(-15px); - height: 50px; + height: 40px; border-radius: 10px; + position: absolute; + bottom: 10px; border-color: #5b97ff; + width: 90% } + .chat-wrapper { + display: flex; + flex-direction: column; + width: 100%; + max-height: calc(100vh - 80px); + overflow-y: auto; + padding-bottom: 60px; + } + + .chat-bubbles { + margin-top: 20px; + display: flex; + flex-direction: column; + flex-grow: 1; + } + + .chat-bubble { + padding: 10px; + margin-bottom: 10px; + border-radius: 10px; + max-width: 60%; + } + + .user-message { + background-color: #283d53; + align-self: flex-end; + color: whitesmoke; + } + + .chat-message { + background-color: #367ae7; + align-self: flex-start; + color:whitesmoke; + } + + .summarizing { @@ -78,16 +144,14 @@ $highlightedText: #82e0ff; } - } - button { - font-size: 9px; - padding: 10px; - color: #ffffff; - background-color: $button; - border-radius: 5px; + + + } + + .text-btn { &:hover { background-color: $button; diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx index a37e73e27..53bedbb65 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx +++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx @@ -3,7 +3,7 @@ import { Button, IconButton, Type } from 'browndash-components'; import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { CgClose } from 'react-icons/cg'; +import { CgClose, CgPathBack, CgArrowLeftO, CgCornerUpLeft } from 'react-icons/cg'; import ReactLoading from 'react-loading'; import { TypeAnimation } from 'react-type-animation'; import { ClientUtils } from '../../../../ClientUtils'; @@ -18,6 +18,9 @@ import { AnchorMenu } from '../AnchorMenu'; import './GPTPopup.scss'; import { SettingsManager } from '../../../util/SettingsManager'; import { SnappingManager } from '../../../util/SnappingManager'; +import { DocumentView } from '../../nodes/DocumentView'; +import { DocCast } from '../../../../fields/Types'; +import { RTFCast } from '../../../../fields/Types'; export enum GPTPopupMode { SUMMARY, @@ -25,15 +28,31 @@ export enum GPTPopupMode { IMAGE, FLASHCARD, DATA, + CARD, SORT, + QUIZ } +export enum GPTQuizType { + CURRENT = 0, + CHOOSE = 1, + MULTIPLE = 2 + +} + + + + + + interface GPTPopupProps {} @observer export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { // eslint-disable-next-line no-use-before-define static Instance: GPTPopup; + private messagesEndRef: React.RefObject<HTMLDivElement>; + @observable private chatMode: boolean = false; private correlatedColumns: string[] = []; @@ -140,7 +159,8 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { this.sortDesc = t; }; - @observable onSortComplete?: (sortResult: string) => void; + @observable onSortComplete?: (sortResult: string, questionType: string, tag?: string) => void; + @observable onQuizRandom?: () => void; @observable cardsDoneLoading = false; @action setCardsDoneLoading(done: boolean) { @@ -148,28 +168,178 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { this.cardsDoneLoading = done; } + @observable sortRespText: string = '' + + @action setSortRespText(resp: string) { + this.sortRespText = resp + } + + @observable chatSortPrompt: string = "" + + sortPromptChanged = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.chatSortPrompt = e.target.value; + }); + + @observable quizAnswer: string = "" + + quizAnswerChanged = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.quizAnswer = e.target.value; + }); + + @observable conversationArray: string[] = ["Hi! In this pop up, you can ask ChatGPT questions about your documents and filter / sort them. "] + + + /** + * When the cards are in quiz mode in the card view, allows gpt to determine whether the user's answer was correct + * @returns + */ + generateQuiz = async () => { + this.setLoading(true); + this.setSortDone(false); + + const quizType = this.quizMode; + + const selected = DocumentView.SelectedDocs().lastElement(); + + + const questionText = 'Question: ' + StrCast(selected['gptInputText']); + + if (StrCast(selected['gptRubric']) === '') { + const rubricText = 'Rubric: ' + await this.generateRubric(StrCast(selected['gptInputText']), selected) + } + + const rubricText = 'Rubric: ' + (StrCast(selected['gptRubric'])) + const queryText = questionText + ' UserAnswer: ' + this.quizAnswer + '. ' + 'Rubric' + rubricText; + + try { + const res = await gptAPICall(queryText, GPTCallType.QUIZ); + if (!res) { + console.error('GPT call failed'); + return; + } + console.log(res) + this.setQuizResp(res) + this.conversationArray.push(res) + + this.setLoading(false); + this.setSortDone(true); + + } catch (err) { + console.error('GPT call failed'); + } + + + if (this.onQuizRandom){ + this.onQuizRandom() + } + + } + + /** + * Generates a rubric by which to compare the user's answer to + * @param inputText user's answer + * @param doc the doc the user is providing info about + * @returns gpt's response + */ + generateRubric = async (inputText: string, doc:Doc) => { + try { + const res = await gptAPICall(inputText, GPTCallType.RUBRIC); + doc['gptRubric']= res; + return res + } catch (err) { + console.error('GPT call failed'); + } + + } + + + + @observable private regenerateCallback: (() => Promise<void>) | null = null; + + /** + * Callback function that causes the card view to update the childpair string list + * @param callback + */ + @action public setRegenerateCallback(callback: () => Promise<void>) { + this.regenerateCallback = callback; + } + + + + public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false; public createFilteredDoc: (axes?: string[]) => boolean = () => false; public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined; + @observable quizRespText: string = '' + + @action setQuizResp (resp: string) { + this.quizRespText = resp + + } + /** - * Sorts cards in the CollectionCardDeckView + * Generates a response to the user's question depending on the type of their question */ - generateSort = async () => { + generateCard = async () => { + console.log(this.chatSortPrompt + "USER PROMPT") this.setLoading(true); this.setSortDone(false); + if (this.regenerateCallback) { + await this.regenerateCallback(); + } + try { - const res = await gptAPICall(this.sortDesc, GPTCallType.SORT); + // const res = await gptAPICall(this.sortDesc, GPTCallType.SORT, this.chatSortPrompt); + const questionType = await gptAPICall(this.chatSortPrompt, GPTCallType.TYPE); + const questionNumber = questionType.split(' ')[0] + console.log(questionType) + let res = '' + + switch (questionNumber) { + case '1': + case '2': + case '4': + res = await gptAPICall(this.sortDesc, GPTCallType.SUBSET, this.chatSortPrompt); + break + case '6': + res = await gptAPICall(this.sortDesc, GPTCallType.SORT, this.chatSortPrompt); + break + default: + + const selected = DocumentView.SelectedDocs().lastElement(); + const questionText = StrCast(selected!['gptInputText']); + + + res = await gptAPICall(questionText, GPTCallType.INFO, this.chatSortPrompt); + break + } + // Trigger the callback with the result if (this.onSortComplete) { - this.onSortComplete(res || 'Something went wrong :('); + this.onSortComplete(res || 'Something went wrong :(', questionNumber, questionType.split(' ').slice(1).join(' ')); + + let explanation = res + + if (questionType != '5' && questionType != '3'){ + + // Extract explanation surrounded by ------ at the top or both at the top and bottom + const explanationMatch = res.match(/------\s*([\s\S]*?)\s*(?:------|$)/) || []; + explanation = explanationMatch[1] ? explanationMatch[1].trim() : 'No explanation found'; + } + + // Set the extracted explanation to sortRespText + this.setSortRespText(explanation); + this.conversationArray.push(this.sortRespText) + this.scrollToBottom() + console.log(res); } } catch (err) { console.error(err); } - + this.setLoading(false); this.setSortDone(true); }; @@ -305,67 +475,167 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { super(props); makeObservable(this); GPTPopup.Instance = this; + this.messagesEndRef = React.createRef(); + } + + + scrollToBottom = () => { + setTimeout(() => { + // Code to execute after 1 second (1000 ms) + if (this.messagesEndRef.current) { + + this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + + }, 50); + } + componentDidUpdate = () => { if (this.loading) { this.setDone(false); } }; + @observable quizMode : GPTQuizType = GPTQuizType.CURRENT + @action setQuizMode (g: GPTQuizType) { + this.quizMode = g + } + + cardMenu = () => ( + <div className="btns-wrapper-gpt"> + <Button + tooltip="Have ChatGPT sort, tag, define, or filter your cards for you!" + text="Modify/Sort Cards!" + onClick={() => this.setMode(GPTPopupMode.SORT)} + color={StrCast(Doc.UserDoc().userVariantColor)} + type={Type.TERT} + style={{ + width: '100%', + height: '40%', + textAlign: 'center', + color: '#ffffff', + fontSize: '16px', + marginBottom: '10px' + }} + /> + <Button + tooltip="Test your knowledge with ChatGPT!" + text="Quiz Cards!" + onClick={() => { + this.conversationArray = ['Define the selected card!'] + this.setMode(GPTPopupMode.QUIZ) + if (this.onQuizRandom){ + this.onQuizRandom() + } + + + + }} + color={StrCast(Doc.UserDoc().userVariantColor)} + type={Type.TERT} + style={{ + width: '100%', + textAlign: 'center', + color: '#ffffff', + fontSize: '16px', + height: '40%', + + }} + /> + </div> + ) + + + + handleKeyPress = async (e: React.KeyboardEvent, isSort: boolean) => { + if (e.key === 'Enter') { + e.stopPropagation(); + + if (isSort) { + this.conversationArray.push(this.chatSortPrompt); + await this.generateCard(); + this.chatSortPrompt = '' + + } else { + this.conversationArray.push(this.quizAnswer); + await this.generateQuiz(); + this.quizAnswer = '' + + } + + this.scrollToBottom(); + } + } + + cardActual = (opt: GPTPopupMode) => { + const isSort = opt === GPTPopupMode.SORT + return ( + + <div className="btns-wrapper-gpt"> + <div className="chat-wrapper"> + <div className="chat-bubbles"> + {this.conversationArray.map((message, index) => ( + <div + key={index} + className={`chat-bubble ${index % 2 === 1 ? 'user-message' : 'chat-message'}`} + > + {message} + </div> + ))} + {(!this.cardsDoneLoading || this.loading) && ( + <div className={`chat-bubble chat-message`}> + ... + </div> + )} + </div> + + <div ref={this.messagesEndRef} style= {{height: '100px'}} /> + + </div> + + <div className="inputWrapper"> + <input + className="searchBox-input" + defaultValue="" + value={isSort ? this.chatSortPrompt : this.quizAnswer} // Controlled input + autoComplete="off" + onChange={isSort ? this.sortPromptChanged : this.quizAnswerChanged} + onKeyDown={(e) => { + this.handleKeyPress(e, isSort) + + + } + } + type="text" + placeholder={`${isSort ? 'Have ChatGPT sort, tag, define, or filter your cards for you!' : 'Define the selected card!'}`} + /> + </div> + </div> + ); + }; + sortBox = () => ( - <> - <div> - {this.heading('SORTING')} - {this.loading ? ( + <div style = {{height: '80%'}}> + {this.heading(this.mode === GPTPopupMode.SORT ? 'SORTING' : 'QUIZ')} + <> + {!this.cardsDoneLoading? ( <div className="content-wrapper"> <div className="loading-spinner"> <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> - <span>Loading...</span> + {this.loading ? <span>Loading...</span> : <span>Reading Cards...</span>} </div> </div> ) : ( - <> - {!this.cardsDoneLoading ? ( - <div className="content-wrapper"> - <div className="loading-spinner"> - <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> - <span>Reading Cards...</span> - </div> - </div> - ) : ( - !this.sortDone && ( - <div className="btns-wrapper-gpt"> - <Button - tooltip="Have ChatGPT sort your cards for you!" - text="Sort!" - onClick={this.generateSort} - color={StrCast(Doc.UserDoc().userVariantColor)} - type={Type.TERT} - style={{ - width: '90%', // Almost as wide as the container - textAlign: 'center', - color: '#ffffff', // White text - fontSize: '16px', // Adjust font size as needed - }} - /> - </div> - ) - )} - - {this.sortDone && ( - <div> - <div className="content-wrapper"> - <p>{this.text === 'Something went wrong :(' ? 'Something went wrong :(' : 'Sorting done! Feel free to move things around / regenerate :) !'}</p> - <IconButton tooltip="Generate Again" onClick={() => this.setSortDone(false)} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} /> - </div> - </div> - )} - </> + (this.mode === GPTPopupMode.CARD ? this.cardMenu() : this.cardActual(this.mode)) // Call the functions to render JSX )} - </div> - </> + </> + </div> ); + + + imageBox = () => ( <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> {this.heading('GENERATED IMAGE')} @@ -511,15 +781,64 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> { heading = (headingText: string) => ( <div className="summary-heading"> <label className="summary-text">{headingText}</label> - {this.loading ? <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} /> : <IconButton color={StrCast(SettingsManager.userVariantColor)} tooltip="close" icon={<CgClose size="16px" />} onClick={() => this.setVisible(false)} />} + {this.loading ? ( + <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} /> + ) : ( + <> + {(this.mode === GPTPopupMode.SORT || this.mode === GPTPopupMode.QUIZ) && ( + <IconButton + color={StrCast(SettingsManager.userVariantColor)} + tooltip="back" + icon={<CgCornerUpLeft size="16px" />} + onClick={() => this.mode = GPTPopupMode.CARD} + style = {{right: '50px', position: 'absolute'}} + /> + )} + <IconButton + color={StrCast(SettingsManager.userVariantColor)} + tooltip="close" + icon={<CgClose size="16px" />} + onClick={() => { + this.setVisible(false)}} + /> + + </> + )} </div> ); + render() { + let content; + + switch (this.mode) { + case GPTPopupMode.SUMMARY: + content = this.summaryBox(); + break; + case GPTPopupMode.DATA: + content = this.dataAnalysisBox(); + break; + case GPTPopupMode.IMAGE: + content = this.imageBox(); + break; + case GPTPopupMode.SORT: + case GPTPopupMode.CARD: + case GPTPopupMode.QUIZ: + content = this.sortBox(); + break; + default: + content = null; + } + return ( - <div className="summary-box" style={{ display: this.visible ? 'flex' : 'none' }}> - {this.mode === GPTPopupMode.SUMMARY ? this.summaryBox() : this.mode === GPTPopupMode.DATA ? this.dataAnalysisBox() : this.mode === GPTPopupMode.IMAGE ? this.imageBox() : this.mode === GPTPopupMode.SORT ? this.sortBox() : null} + <div + className="summary-box" + style={{ display: this.visible ? 'flex' : 'none' }} + > + {content} + <div className="resize-handle" /> </div> ); } + } |