diff options
Diffstat (limited to 'src')
37 files changed, 698 insertions, 891 deletions
diff --git a/src/ClientUtils.ts b/src/ClientUtils.ts index 972910071..e8165d5ab 100644 --- a/src/ClientUtils.ts +++ b/src/ClientUtils.ts @@ -655,6 +655,27 @@ export function dateRangeStrToDates(dateStr: string) { return { start: new Date(dateRangeParts[0]), end: new Date(dateRangeParts[1]) }; } +/** + * converts the image to base url formate + * @param imageUrl imageurl taken from the collection icon + */ +export async function imageUrlToBase64(imageUrl: string): Promise<string> { + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = error => reject(error); + }); + } catch (error) { + console.error('Error:', error); + throw error; + } +} + function replaceCanvases(oldDiv: HTMLElement, newDiv: HTMLElement) { if (oldDiv.childNodes && newDiv) { for (let i = 0; i < oldDiv.childNodes.length; i++) { diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts index acaefe783..5f54f9d0a 100644 --- a/src/client/documents/DocUtils.ts +++ b/src/client/documents/DocUtils.ts @@ -1,5 +1,3 @@ -/* eslint-disable prefer-destructuring */ -/* eslint-disable default-param-last */ /* eslint-disable no-use-before-define */ import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { saveAs } from 'file-saver'; diff --git a/src/client/util/UndoManager.ts b/src/client/util/UndoManager.ts index ce0e7768b..07d3bb708 100644 --- a/src/client/util/UndoManager.ts +++ b/src/client/util/UndoManager.ts @@ -43,7 +43,6 @@ export function undoable<T>(fn: (...args: any[]) => T, batchName: string): (...a return function (...fargs) { const batch = UndoManager.StartBatch(batchName); try { - // eslint-disable-next-line prefer-rest-params return fn.apply(undefined, fargs); } finally { batch.end(); @@ -51,9 +50,9 @@ export function undoable<T>(fn: (...args: any[]) => T, batchName: string): (...a }; } -// eslint-disable-next-line no-redeclare, @typescript-eslint/no-explicit-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function undoBatch(target: any, key: string | symbol, descriptor?: TypedPropertyDescriptor<any>): any; -// eslint-disable-next-line no-redeclare, @typescript-eslint/no-explicit-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function undoBatch(target: any, key?: string | symbol, descriptor?: TypedPropertyDescriptor<(...args: any[]) => unknown>): any { if (!key) { return function (...fargs: unknown[]) { diff --git a/src/client/views/FilterPanel.tsx b/src/client/views/FilterPanel.tsx index e34b66963..11425e477 100644 --- a/src/client/views/FilterPanel.tsx +++ b/src/client/views/FilterPanel.tsx @@ -1,3 +1,4 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { action, computed, makeObservable, observable, ObservableMap } from 'mobx'; @@ -12,18 +13,15 @@ import { DocData } from '../../fields/DocSymbols'; import { Id } from '../../fields/FieldSymbols'; import { List } from '../../fields/List'; import { RichTextField } from '../../fields/RichTextField'; -import { DocCast, StrCast } from '../../fields/Types'; -import { Button, CurrentUserUtils } from '../util/CurrentUserUtils'; +import { StrCast } from '../../fields/Types'; import { SearchUtil } from '../util/SearchUtil'; import { SnappingManager } from '../util/SnappingManager'; import { undoable } from '../util/UndoManager'; import { FieldsDropdown } from './FieldsDropdown'; import './FilterPanel.scss'; import { DocumentView } from './nodes/DocumentView'; -import { ButtonType } from './nodes/FontIconBox/FontIconBox'; import { Handle, Tick, TooltipRail, Track } from './nodes/SliderBox-components'; import { ObservableReactComponent } from './ObservableReactComponent'; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; interface HotKeyButtonProps { hotKey: Doc; @@ -159,6 +157,7 @@ const HotKeyIconButton: React.FC<HotKeyButtonProps> = observer(({ hotKey /*, sel interface filterProps { Document: Doc; + addHotKey: (hotKey: string) => void; } @observer @@ -357,33 +356,6 @@ export class FilterPanel extends ObservableReactComponent<filterProps> { }; /** - * 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 title = hotKey.startsWith('#') ? hotKey.substring(1) : hotKey; - - const newKey: Button = { - title, - icon: 'question', - toolTip: `Click to toggle the ${title}'s group's visibility`, - btnType: ButtonType.ToggleButton, - expertMode: false, - toolType: '#' + title, - funcs: {}, - scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}' }, - }; - - const newBtn = CurrentUserUtils.setupContextMenuBtn(newKey, filter); - newBtn.isSystem = newBtn[DocData].isSystem = undefined; - - Doc.AddToFilterHotKeys(newBtn); - }; - - /** * Renders the newly formed hotkey icon buttons * @returns the buttons to be rendered */ @@ -472,7 +444,7 @@ export class FilterPanel extends ObservableReactComponent<filterProps> { <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]} /> + <FieldsDropdown Document={this.Document} selectFunc={this._props.addHotKey} showPlaceholder placeholder="add a hotkey" addedFields={['acl_Guest', LinkedTo]} /> </div> </div> </div> diff --git a/src/client/views/GestureOverlay.tsx b/src/client/views/GestureOverlay.tsx index 5fddaec9a..afeecaa63 100644 --- a/src/client/views/GestureOverlay.tsx +++ b/src/client/views/GestureOverlay.tsx @@ -70,7 +70,6 @@ export class GestureOverlay extends ObservableReactComponent<React.PropsWithChil @observable private _strokes: InkData[] = []; @observable private _palette?: JSX.Element = undefined; @observable private _clipboardDoc?: JSX.Element = undefined; - @observable private _possibilities: JSX.Element[] = []; @computed private get height(): number { return 2 * Math.max(this._pointerY && this._thumbY ? this._thumbY - this._pointerY : 100, 100); @@ -209,8 +208,8 @@ export class GestureOverlay extends ObservableReactComponent<React.PropsWithChil const intersectArray: string[] = []; const scribbleBounds = InkField.getBounds(scribble); for (let i = 0; i < scribble.length - 3; i += 4) { // for each segment of scribble + const scribbleSeg = InkField.Segment(scribble, i); for (let j = 0; j < inkStroke.length - 3; j += 4) { // for each segment of ink stroke - const scribbleSeg = InkField.Segment(scribble, i); const strokeSeg = InkField.Segment(inkStroke, j); const strokeBounds = InkField.getBounds(strokeSeg.points.map(pt => ({ X: pt.x, Y: pt.y }))); if (intersectRect(scribbleBounds, strokeBounds)) { diff --git a/src/client/views/InkTranscription.tsx b/src/client/views/InkTranscription.tsx index 24d53a8c8..c5a9d3ba4 100644 --- a/src/client/views/InkTranscription.tsx +++ b/src/client/views/InkTranscription.tsx @@ -1,19 +1,19 @@ import * as iink from 'iink-ts'; import { action, observable } from 'mobx'; import * as React from 'react'; +import { imageUrlToBase64 } from '../../ClientUtils'; +import { aggregateBounds } from '../../Utils'; import { Doc, DocListCast } from '../../fields/Doc'; import { InkData, InkField, InkTool } from '../../fields/InkField'; import { Cast, DateCast, ImageCast, NumCast } from '../../fields/Types'; -import { aggregateBounds } from '../../Utils'; +import { ImageField, URLField } from '../../fields/URLField'; +import { gptHandwriting } from '../apis/gpt/GPT'; import { DocumentType } from '../documents/DocumentTypes'; -import { CollectionFreeFormView, MarqueeView } from './collections/collectionFreeForm'; -import { InkingStroke } from './InkingStroke'; -import './InkTranscription.scss'; import { Docs } from '../documents/Documents'; +import './InkTranscription.scss'; +import { InkingStroke } from './InkingStroke'; +import { CollectionFreeFormView, MarqueeView } from './collections/collectionFreeForm'; import { DocumentView } from './nodes/DocumentView'; -import { ImageField } from '../../fields/URLField'; -import { gptHandwriting } from '../apis/gpt/GPT'; -import { URLField } from '../../fields/URLField'; /** * Class component that handles inking in writing mode */ @@ -260,7 +260,7 @@ export class InkTranscription extends React.Component { const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; let response; try { - const hrefBase64 = await this.imageUrlToBase64(hrefComplete); + const hrefBase64 = await imageUrlToBase64(hrefComplete); response = await gptHandwriting(hrefBase64); } catch { console.error('Error getting image'); @@ -291,26 +291,6 @@ export class InkTranscription extends React.Component { } return undefined; } - /** - * converts the image to base url formate - * @param imageUrl imageurl taken from the collection icon - */ - imageUrlToBase64 = async (imageUrl: string): Promise<string> => { - try { - const response = await fetch(imageUrl); - const blob = await response.blob(); - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = error => reject(error); - }); - } catch (error) { - console.error('Error:', error); - throw error; - } - }; /** * Creates the ink grouping once the user leaves the writing mode. diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index 73d2872d1..17eea585a 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -18,7 +18,6 @@ import { TrackMovements } from '../util/TrackMovements'; import { KeyManager } from './GlobalKeyHandler'; import { InkingStroke } from './InkingStroke'; import { MainView } from './MainView'; -import { CollectionCalendarView } from './collections/CollectionCalendarView'; import { CollectionDockingView } from './collections/CollectionDockingView'; import { CollectionView } from './collections/CollectionView'; import { TabDocView } from './collections/TabDocView'; @@ -128,7 +127,6 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' }; CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, - CollectionCalendarView, CollectionView, WebBox, KeyValueBox, diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 76c67a252..a3fa1c18b 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -20,6 +20,7 @@ import { CollectionViewType, DocumentType } from '../documents/DocumentTypes'; import { Docs } from '../documents/Documents'; import { CalendarManager } from '../util/CalendarManager'; import { CaptureManager } from '../util/CaptureManager'; +import { Button, CurrentUserUtils } from '../util/CurrentUserUtils'; import { DocumentManager } from '../util/DocumentManager'; import { DragManager } from '../util/DragManager'; import { dropActionType } from '../util/DropActionTypes'; @@ -41,6 +42,7 @@ import { DashboardView } from './DashboardView'; import { DictationOverlay } from './DictationOverlay'; import { DocumentDecorations } from './DocumentDecorations'; import { GestureOverlay } from './GestureOverlay'; +import { InkTranscription } from './InkTranscription'; import { LightboxView } from './LightboxView'; import './MainView.scss'; import { ObservableReactComponent } from './ObservableReactComponent'; @@ -60,6 +62,7 @@ import { LinkMenu } from './linking/LinkMenu'; import { SchemaCSVPopUp } from './nodes/DataVizBox/SchemaCSVPopUp'; import { DocButtonState } from './nodes/DocumentLinksButton'; import { DocumentView, DocumentViewInternal } from './nodes/DocumentView'; +import { ButtonType } from './nodes/FontIconBox/FontIconBox'; import { ImageEditorData as ImageEditor } from './nodes/ImageBox'; import { LinkDescriptionPopup } from './nodes/LinkDescriptionPopup'; import { LinkDocPreview, LinkInfo } from './nodes/LinkDocPreview'; @@ -73,9 +76,8 @@ import GenerativeFill from './nodes/generativeFill/GenerativeFill'; import { PresBox } from './nodes/trails'; import { AnchorMenu } from './pdf/AnchorMenu'; import { GPTPopup } from './pdf/GPTPopup/GPTPopup'; -import { TopBar } from './topbar/TopBar'; import { SmartDrawHandler } from './smartdraw/SmartDrawHandler'; -import { InkTranscription } from './InkTranscription'; +import { TopBar } from './topbar/TopBar'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } = require('./global/globalCssVariables.module.scss'); // prettier-ignore @@ -851,6 +853,33 @@ export class MainView extends ObservableReactComponent<object> { return true; }; + /** + * 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 title = hotKey.startsWith('#') ? hotKey.substring(1) : hotKey; + + const newKey: Button = { + title, + icon: 'question', + toolTip: `Click to toggle the ${title}'s group's visibility`, + btnType: ButtonType.ToggleButton, + expertMode: false, + toolType: '#' + title, + funcs: {}, + scripts: { onClick: '{ return handleTags(this.toolType, _readOnly_);}' }, + }; + + const newBtn = CurrentUserUtils.setupContextMenuBtn(newKey, filter); + newBtn.isSystem = newBtn[DocData].isSystem = undefined; + + Doc.AddToFilterHotKeys(newBtn); + }; + @computed get mainInnerContent() { const leftMenuFlyoutWidth = this._leftMenuFlyoutWidth + this.leftMenuWidth(); const width = this.propertiesWidth() + leftMenuFlyoutWidth; @@ -879,7 +908,7 @@ export class MainView extends ObservableReactComponent<object> { )} <div className="properties-container" style={{ width: this.propertiesWidth(), color: SnappingManager.userColor }}> <div style={{ display: this.propertiesWidth() < 10 ? 'none' : undefined }}> - <PropertiesView styleProvider={DefaultStyleProvider} addDocTab={DocumentViewInternal.addDocTabFunc} width={this.propertiesWidth()} height={this.propertiesHeight()} /> + <PropertiesView styleProvider={DefaultStyleProvider} addHotKey={this.addHotKey} addDocTab={DocumentViewInternal.addDocTabFunc} width={this.propertiesWidth()} height={this.propertiesHeight()} /> </div> </div> </div> diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index 71d184497..371d34173 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -50,6 +50,7 @@ interface PropertiesViewProps { height: number; styleProvider?: StyleProviderFuncType; addDocTab: (doc: Doc, where: OpenWhere) => boolean; + addHotKey: (hotKey: string) => void; } @observer @@ -1279,7 +1280,7 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps return ( <PropertiesSection title="Filters" isOpen={this.openFilters} setIsOpen={action(bool => { this.openFilters = bool; })} onDoubleClick={this.CloseAll}> <div className="propertiesView-content filters" style={{ position: 'relative', height: 'auto' }}> - <FilterPanel Document={this.selectedDoc ?? Doc.ActiveDashboard!} /> + <FilterPanel Document={this.selectedDoc ?? Doc.ActiveDashboard!} addHotKey={this._props.addHotKey}/> </div> </PropertiesSection> ); // prettier-ignore diff --git a/src/client/views/ScriptingRepl.tsx b/src/client/views/ScriptingRepl.tsx index 2de867746..8ab91a6b5 100644 --- a/src/client/views/ScriptingRepl.tsx +++ b/src/client/views/ScriptingRepl.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-array-index-key */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; @@ -182,7 +181,7 @@ export class ScriptingRepl extends ObservableReactComponent<object> { this.maybeScrollToBottom(); return; } - const result = undoable(() => script.run({}, e => this.commands.push({ command: this.commandString, result: e as string })), 'run:' + this.commandString)(); + const result = undoable(() => script.run({}, err => this.commands.push({ command: this.commandString, result: err as string })), 'run:' + this.commandString)(); if (result.success) { this.commands.push({ command: this.commandString, result: result.result }); this.commandsHistory.push(this.commandString); diff --git a/src/client/views/StyleProp.ts b/src/client/views/StyleProp.ts index dd5b98cfe..44d3bf757 100644 --- a/src/client/views/StyleProp.ts +++ b/src/client/views/StyleProp.ts @@ -21,4 +21,6 @@ export enum StyleProp { FontFamily = 'fontFamily', // font family of text FontWeight = 'fontWeight', // font weight of text Highlighting = 'highlighting', // border highlighting + ContextMenuItems = 'contextMenuItems', // menu items to add to context menu + AnchorMenuItems = 'anchorMenuItems', } diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 16f6aa40b..44bea57eb 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -21,6 +21,7 @@ import { TreeSort } from './collections/TreeSort'; import { Colors } from './global/globalEnums'; import { DocumentView, DocumentViewProps } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; +import { styleProviderQuiz } from './StyleProviderQuiz'; import { StyleProp } from './StyleProp'; import './StyleProvider.scss'; import { TagsView } from './TagsView'; @@ -89,6 +90,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & containerViewPath, childFilters, hideCaptions, + hideFilterStatus, showTitle, childFiltersByRanges, renderDepth, @@ -110,6 +112,12 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & const color = () => styleProvider?.(doc, props, StyleProp.Color) as string; const opacity = () => styleProvider?.(doc, props, StyleProp.Opacity); const layoutShowTitle = () => styleProvider?.(doc, props, StyleProp.ShowTitle) as string; + + // bcz: For now, this is how to add custom-stylings (like a Quiz styling) for app-specific purposes. The quiz styling will short-circuit + // the regular stylings for items that it controls (eg., things with a quiz field, or images) + const quizProp = styleProviderQuiz.quizStyleProvider(doc, props, property); + if (quizProp !== undefined) return quizProp; + // prettier-ignore switch (property.split(':')[0]) { case StyleProp.TreeViewIcon: { @@ -318,7 +326,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & : childFilters?.().filter(f => ClientUtils.IsRecursiveFilter(f) && f !== ClientUtils.noDragDocsFilter).length || childFiltersByRanges?.().length ? 'orange' // 'inheritsFilter' : undefined; - return !showFilterIcon || props?.hideFilterStatus ? null : ( + return !showFilterIcon || hideFilterStatus ? null : ( <div className="styleProvider-filter"> <Dropdown type={Type.TERT} diff --git a/src/client/views/StyleProviderQuiz.scss b/src/client/views/StyleProviderQuiz.scss new file mode 100644 index 000000000..2f52c8dec --- /dev/null +++ b/src/client/views/StyleProviderQuiz.scss @@ -0,0 +1,40 @@ +.loading-spinner { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + // left: 50%; + // top: 50%; + z-index: 200; + font-size: 20px; + font-weight: bold; + color: #17175e; +} + +.check-icon { + position: absolute; + right: 40; + bottom: 10; + color: green; + display: inline-block; + font-size: 20px; + overflow: hidden; +} + +.redo-icon { + position: absolute; + right: 10; + bottom: 10; + color: black; + display: inline-block; + font-size: 20px; + overflow: hidden; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx new file mode 100644 index 000000000..1f2ad1485 --- /dev/null +++ b/src/client/views/StyleProviderQuiz.tsx @@ -0,0 +1,398 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Tooltip } from '@mui/material'; +import axios from 'axios'; +import * as React from 'react'; +import { returnFalse, setupMoveUpEvents } from '../../ClientUtils'; +import { emptyFunction } from '../../Utils'; +import { Doc, DocListCast, Opt } from '../../fields/Doc'; +import { DocData } from '../../fields/DocSymbols'; +import { List } from '../../fields/List'; +import { NumCast, StrCast } from '../../fields/Types'; +import { GPTCallType, gptAPICall, gptImageLabel } from '../apis/gpt/GPT'; +import { Docs } from '../documents/Documents'; +import { ContextMenu } from './ContextMenu'; +import { ContextMenuProps } from './ContextMenuItem'; +import { StyleProp } from './StyleProp'; +import { AnchorMenu } from './pdf/AnchorMenu'; +import { DocumentViewProps } from './nodes/DocumentView'; +import { FieldViewProps } from './nodes/FieldView'; +import { ImageBox } from './nodes/ImageBox'; +import { ImageUtility } from './nodes/generativeFill/generativeFillUtils/ImageHandler'; +import './StyleProviderQuiz.scss'; + +export namespace styleProviderQuiz { + enum quizMode { + SMART = 'smart', + NORMAL = 'normal', + NONE = 'none', + } + + async function selectUrlToBase64(blob: Blob): Promise<string> { + try { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = error => reject(error); + }); + } catch (error) { + console.error('Error:', error); + throw error; + } + } + /** + * Creates label boxes over text on the image to be filled in. + * @param boxes + * @param texts + */ + async function createBoxes(img: ImageBox, boxes: [[[number, number]]], texts: [string]) { + img.Document._quizBoxes = new List<Doc>([]); + for (let i = 0; i < boxes.length; i++) { + const coords = boxes[i] ? boxes[i] : []; + const width = coords[1][0] - coords[0][0]; + const height = coords[2][1] - coords[0][1]; + const text = texts[i]; + + const newCol = Docs.Create.LabelDocument({ + _width: width, + _height: height, + _layout_fitWidth: true, + title: '', + }); + const scaling = 1 / (img._props.NativeDimScaling?.() || 1); + newCol.x = coords[0][0] + NumCast(img.marqueeref.current?.left) * scaling; + newCol.y = coords[0][1] + NumCast(img.marqueeref.current?.top) * scaling; + + newCol.zIndex = 1000; + newCol.forceActive = true; + newCol.quiz = text; + newCol[DocData].textTransform = 'none'; + Doc.AddDocToList(img.Document, '_quizBoxes', newCol); + img.addDocument(newCol); + // img._loading = false; + } + } + + /** + * Calls backend to find any text on an image. Gets the text and the + * coordinates of the text and creates label boxes at those locations. + * @param quiz + * @param i + */ + async function pushInfo(imgBox: ImageBox, quiz: quizMode, i?: string) { + imgBox.Document._quizMode = quiz; + const quizBoxes = DocListCast(imgBox.Document.quizBoxes); + if (!quizBoxes.length) { + imgBox.Loading = true; + + const img = { + file: i ? i : imgBox.paths[0], + drag: i ? 'drag' : 'full', + smart: quiz, + }; + const response = await axios.post('http://localhost:105/labels/', img, { + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.data['boxes'].length != 0) { + createBoxes(imgBox, response.data['boxes'], response.data['text']); + } else { + imgBox.Loading = false; + } + } else quizBoxes.forEach(box => (box.hidden = false)); + } + + async function createCanvas(img: ImageBox) { + const canvas = document.createElement('canvas'); + const scaling = 1 / (img._props.NativeDimScaling?.() || 1); + const w = AnchorMenu.Instance.marqueeWidth * scaling; + const h = AnchorMenu.Instance.marqueeHeight * scaling; + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); // draw image to canvas. scale to target dimensions + if (ctx) { + img.imageRef && ctx.drawImage(img.imageRef, NumCast(img.marqueeref.current?.left) * scaling, NumCast(img.marqueeref.current?.top) * scaling, w, h, 0, 0, w, h); + } + const blob = await ImageUtility.canvasToBlob(canvas); + return selectUrlToBase64(blob); + } + /** + * Create flashcards from an image. + */ + async function getImageDesc(img: ImageBox) { + img.Loading = true; + try { + const hrefBase64 = await createCanvas(img); + const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this image with each question and answer labeled as "question" and "answer". Do not label each flashcard and do not include asterisks: '); + AnchorMenu.Instance.transferToFlashcard(response, NumCast(img.layoutDoc['x']), NumCast(img.layoutDoc['y'])); + } catch (error) { + console.log('Error', error); + } + img.Loading = false; + } + + /** + * Calls the createCanvas and pushInfo methods to convert the + * image to a form that can be passed to GPT and find the locations + * of the text. + */ + async function makeLabels(img: ImageBox) { + try { + const hrefBase64 = await createCanvas(img); + pushInfo(img, quizMode.NORMAL, hrefBase64); + } catch (error) { + console.log('Error', error); + } + } + + /** + * Determines whether two words should be considered + * the same, allowing minor typos. + * @param str1 + * @param str2 + * @returns + */ + function levenshteinDistance(str1: string, str2: string) { + const len1 = str1.length; + const len2 = str2.length; + const dp = Array.from(Array(len1 + 1), () => Array(len2 + 1).fill(0)); + + if (len1 === 0) return len2; + if (len2 === 0) return len1; + + for (let i = 0; i <= len1; i++) dp[i][0] = i; + for (let j = 0; j <= len2; j++) dp[0][j] = j; + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, // deletion + dp[i][j - 1] + 1, // insertion + dp[i - 1][j - 1] + cost // substitution + ); + } + } + + return dp[len1][len2]; + } + + /** + * Different algorithm for determining string similarity. + * @param str1 + * @param str2 + * @returns + */ + function jaccardSimilarity(str1: string, str2: string) { + const set1 = new Set(str1.split(' ')); + const set2 = new Set(str2.split(' ')); + + const intersection = new Set([...set1].filter(x => set2.has(x))); + const union = new Set([...set1, ...set2]); + + return intersection.size / union.size; + } + + /** + * Averages the jaccardSimilarity and levenshteinDistance scores + * to determine string similarity for the labelboxes answers and + * the users response. + * @param str1 + * @param str2 + * @returns + */ + function stringSimilarity(str1: string, str2: string) { + const levenshteinDist = levenshteinDistance(str1, str2); + const levenshteinScore = 1 - levenshteinDist / Math.max(str1.length, str2.length); + + const jaccardScore = jaccardSimilarity(str1, str2); + + // Combine the scores with a higher weight on Jaccard similarity + return 0.5 * levenshteinScore + 0.5 * jaccardScore; + } + /** + * Returns whether two strings are similar + * @param input + * @param target + * @returns + */ + function compareWords(input: string, target: string) { + const distance = stringSimilarity(input.toLowerCase(), target.toLowerCase()); + return distance >= 0.7; + } + + /** + * GPT returns a hex color for what color the label box should be based on + * the correctness of the users answer. + * @param inputString + * @returns + */ + function extractHexAndSentences(inputString: string) { + // Regular expression to match a hexadecimal number at the beginning followed by a period and sentences + const regex = /^#([0-9A-Fa-f]+)\.\s*(.+)$/s; + const match = inputString.match(regex); + + if (match) { + const hexNumber = match[1]; + const sentences = match[2].trim(); + return { hexNumber, sentences }; + } else { + return { error: 'The input string does not match the expected format.' }; + } + } + function imgQuizBoxes(img: ImageBox) { + return DocListCast(img.Document.quizBoxes); + } + function imgQuizMode(img: ImageBox) { + return StrCast(img.Document._quizMode); + } + + /** + * Check whether the contents of the label boxes on an image are correct. + */ + function check(img: ImageBox) { + //this._loading = true; + imgQuizBoxes(img).forEach(async doc => { + const input = StrCast(doc[DocData].title); + if (imgQuizMode(img) == quizMode.SMART && input) { + const questionText = 'Question: What was labeled in this image?'; + const rubricText = ' Rubric: ' + StrCast(doc.quiz); + const queryText = + questionText + + ' UserAnswer: ' + + input + + '. ' + + rubricText + + '. One sentence and evaluate based on meaning, not wording. Provide a hex color at the beginning with a period after it on a scale of green (minor details missed) to red (big error) for how correct the answer is. Example: "#FFFFFF. Pasta is delicious."'; + const response = await gptAPICall(queryText, GPTCallType.QUIZ); + const hexSent = extractHexAndSentences(response); + doc.quiz = hexSent.sentences?.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric'); + doc.backgroundColor = '#' + hexSent.hexNumber; + } else { + const match = compareWords(input, StrCast(doc.quiz).trim()); + doc.backgroundColor = match ? '#11c249' : '#eb2d2d'; + } + }); + //this._loading = false; + } + + function redo(img: ImageBox) { + imgQuizBoxes(img).forEach(doc => { + doc[DocData].title = ''; + doc.backgroundColor = '#e4e4e4'; + }); + } + + /** + * Get rid of all the label boxes on the images. + */ + function exitQuizMode(img: ImageBox) { + img.Document._quizMode = quizMode.NONE; + DocListCast(img.Document._quizBoxes).forEach(box => { + box.hidden = true; + }); + } + + export function quizStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & DocumentViewProps>, property: string) { + const editLabelAnswer = (qdoc: Doc) => { + // when click the pencil, set the text to the quiz content. when click off, set the quiz text to that and set textbox to nothing. + if (!qdoc._editLabel) { + qdoc.title = StrCast(qdoc.quiz); + } else { + qdoc.quiz = StrCast(qdoc.title); + qdoc.title = ''; + } + qdoc._editLabel = !qdoc._editLabel; + }; + const editAnswer = (qdoc: Opt<Doc>) => { + return ( + <Tooltip + title={ + <div className="answer-tooltip" style={{ minWidth: '150px' }}> + {qdoc?._editLabel ? 'save' : 'edit correct answer'} + </div> + }> + <div className="answer-tool-tip" onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => qdoc && editLabelAnswer(qdoc))}> + <FontAwesomeIcon className="edit-icon" color={qdoc?._editLabel ? 'white' : 'black'} icon="pencil" size="sm" /> + </div> + </Tooltip> + ); + }; + const answerIcon = (qdoc: Opt<Doc>) => { + return ( + <Tooltip + title={ + <div className="answer-tooltip" style={{ minWidth: '150px' }}> + {StrCast(qdoc?.quiz ?? '')} + </div> + }> + <div className="answer-tool-tip"> + <FontAwesomeIcon className="q-icon" icon="circle" color="white" /> + <FontAwesomeIcon className="answer-icon" icon="question" /> + </div> + </Tooltip> + ); + }; + const checkIcon = (img: ImageBox) => ( + <Tooltip title={<div className="dash-tooltip">Check</div>}> + <div className="check-icon" onPointerDown={() => check(img)}> + <FontAwesomeIcon icon="circle-check" size="lg" /> + </div> + </Tooltip> + ); + const redoIcon = (img: ImageBox) => ( + <Tooltip title={<div className="dash-tooltip">Redo</div>}> + <div className="redo-icon" onPointerDown={() => redo(img)}> + <FontAwesomeIcon icon="redo-alt" size="lg" /> + </div> + </Tooltip> + ); + + const imgBox = props?.DocumentView?.().ComponentView as ImageBox; + switch (property) { + case StyleProp.Decorations: + { + if (doc?.quiz) { + // this should only be set on Labels that are part of an image quiz + return ( + <> + {editAnswer(doc?.[DocData])} + {answerIcon(doc)} + </> + ); + } else if (imgBox?.Document._quizMode && imgBox.Document._quizMode !== quizMode.NONE) { + return ( + <> + {checkIcon(imgBox)} + {redoIcon(imgBox)} + </> + ); + } + } + break; + case StyleProp.ContextMenuItems: + if (imgBox) { + const quizes: ContextMenuProps[] = []; + quizes.push({ + description: 'Smart Check', + event: doc?.quizMode == quizMode.NONE ? () => pushInfo(imgBox, quizMode.SMART) : () => exitQuizMode(imgBox), + icon: 'pen-to-square', + }); + quizes.push({ + description: 'Normal', + event: doc?.quizMode == quizMode.NONE ? () => pushInfo(imgBox, quizMode.NORMAL) : () => exitQuizMode(imgBox), + icon: 'pencil', + }); + ContextMenu.Instance?.addItem({ description: 'Quiz Mode', subitems: quizes, icon: 'file-pen' }); + } + break; + case StyleProp.AnchorMenuItems: + if (imgBox) { + AnchorMenu.Instance.gptFlashcards = () => getImageDesc(imgBox); + AnchorMenu.Instance.makeLabels = () => makeLabels(props?.DocumentView?.().ComponentView as ImageBox); + } + } + return undefined; + } +} diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx index 9858e7b61..072cae3af 100644 --- a/src/client/views/TagsView.tsx +++ b/src/client/views/TagsView.tsx @@ -84,9 +84,7 @@ export class TagItem extends ObservableReactComponent<TagItemProps> { */ public static allDocsWithTag = (tag: string) => DocListCast(TagItem.findTagCollectionDoc(tag)?.[DocData].docs); - public static docHasTag = (doc: Doc, tag: string) => { - return StrListCast(doc?.tags).includes(tag); - }; + public static docHasTag = (doc: Doc, tag: string) => StrListCast(doc?.tags).includes(tag); /** * Adds a tag to the metadata of this document and adds the Doc to the corresponding tag collection Doc (or creates it) * @param tag tag string diff --git a/src/client/views/collections/CollectionCalendarView.tsx b/src/client/views/collections/CollectionCalendarView.tsx deleted file mode 100644 index 0ea9f8ebc..000000000 --- a/src/client/views/collections/CollectionCalendarView.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { computed, makeObservable } from 'mobx'; -import { observer } from 'mobx-react'; -import * as React from 'react'; -import { emptyFunction } from '../../../Utils'; -import { dateRangeStrToDates, returnTrue } from '../../../ClientUtils'; -import { Doc, DocListCast } from '../../../fields/Doc'; -import { StrCast } from '../../../fields/Types'; -import { CollectionStackingView } from './CollectionStackingView'; -import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; - -@observer -export class CollectionCalendarView extends CollectionSubView() { - constructor(props: SubCollectionViewProps) { - super(props); - makeObservable(this); - } - - componentDidMount(): void {} - - componentWillUnmount(): void {} - - @computed get allCalendars() { - return this.childDocs; // returns a list of docs (i.e. calendars) - } - - removeCalendar = () => {}; - - addCalendar = (/* doc: Doc | Doc[], annotationKey?: string | undefined */): boolean => - // bring up calendar modal with option to create a calendar - true; - - _stackRef = React.createRef<CollectionStackingView>(); - - panelHeight = () => this._props.PanelHeight() - 40; // this should be the height of the stacking view. For now, it's the hieight of the calendar view minus 40 to allow for a title - - // most recent calendar should come first - sortByMostRecentDate = (calendarA: Doc, calendarB: Doc) => { - const aDateRangeStr = StrCast(DocListCast(calendarA.data).lastElement()?.date_range); - const bDateRangeStr = StrCast(DocListCast(calendarB.data).lastElement()?.date_range); - - const { start: aFromDate, end: aToDate } = dateRangeStrToDates(aDateRangeStr); - const { start: bFromDate, end: bToDate } = dateRangeStrToDates(bDateRangeStr); - - if (aFromDate > bFromDate) { - return -1; // a comes first - } - if (aFromDate < bFromDate) { - return 1; // b comes first - } - // start dates are the same - if (aToDate > bToDate) { - return -1; // a comes first - } - if (aToDate < bToDate) { - return 1; // b comes first - } - return 0; // same start and end dates - }; - - screenToLocalTransform = () => - this._props - .ScreenToLocalTransform() - .translate(Doc.NativeWidth(this.Document), 0) - .scale(this._props.NativeDimScaling?.() || 1); - - get calendarsKey() { - return this._props.fieldKey; - } - - render() { - return ( - <div className="collectionCalendarView"> - <CollectionStackingView - // eslint-disable-next-line react/jsx-props-no-spreading - {...this._props} - setContentViewBox={emptyFunction} - ref={this._stackRef} - PanelHeight={this.panelHeight} - PanelWidth={this._props.PanelWidth} - sortFunc={this.sortByMostRecentDate} - setHeight={undefined} - isAnnotationOverlay={false} - // select={emptyFunction} What does this mean? - isAnyChildContentActive={returnTrue} // ?? - dontCenter="y" - // childDocumentsActive={} - // whenChildContentsActiveChanged={} - childHideDecorationTitle={false} - removeDocument={this.removeDocument} // will calendar automatically be removed from myCalendars - moveDocument={this.moveDocument} - addDocument={this.addCalendar} - ScreenToLocalTransform={this.screenToLocalTransform} - renderDepth={this._props.renderDepth + 1} - fieldKey={this.calendarsKey} - /> - </div> - ); - } -} diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx index 37612ff5f..c61b0b4dd 100644 --- a/src/client/views/collections/CollectionCardDeckView.tsx +++ b/src/client/views/collections/CollectionCardDeckView.tsx @@ -1,8 +1,8 @@ import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; +import { computedFn } from 'mobx-utils'; import * as React from 'react'; -import { ClientUtils, DashColor, returnFalse, returnZero } from '../../../ClientUtils'; -import { emptyFunction } from '../../../Utils'; +import { ClientUtils, DashColor, imageUrlToBase64, returnFalse, returnZero } from '../../../ClientUtils'; import { Doc } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; @@ -13,7 +13,6 @@ import { gptImageLabel } from '../../apis/gpt/GPT'; import { DocumentType } from '../../documents/DocumentTypes'; import { DragManager } from '../../util/DragManager'; import { dropActionType } from '../../util/DropActionTypes'; -import { SelectionManager } from '../../util/SelectionManager'; import { SnappingManager } from '../../util/SnappingManager'; import { Transform } from '../../util/Transform'; import { undoable } from '../../util/UndoManager'; @@ -28,7 +27,6 @@ enum cardSortings { Time = 'time', Type = 'type', Color = 'color', - Custom = 'custom', Chat = 'chat', Tag = 'tag', None = '', @@ -46,31 +44,13 @@ export class CollectionCardView extends CollectionSubView() { private _dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [key: string]: IReactionDisposer } = {}; private _textToDoc = new Map<string, Doc>(); - private _dropped = false; // indicate when a card doc has just moved; + private _dropped = false; // set when a card doc has just moved and the drop method has been called - prevents the pointerUp method from hiding doc decorations (which needs to be done when clicking on a card to animate it to front/center) @observable _forceChildXf = 0; @observable _hoveredNodeIndex = -1; @observable _docRefs = new ObservableMap<Doc, DocumentView>(); @observable _maxRowCount = 10; @observable _docDraggedIndex: number = -1; - @observable overIndex: number = -1; - - static imageUrlToBase64 = async (imageUrl: string): Promise<string> => { - try { - const response = await fetch(imageUrl); - const blob = await response.blob(); - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = error => reject(error); - }); - } catch (error) { - console.error('Error:', error); - throw error; - } - }; constructor(props: SubCollectionViewProps) { super(props); @@ -101,7 +81,6 @@ export class CollectionCardView extends CollectionSubView() { componentDidMount() { this._props.setContentViewBox?.(this); - // Reaction to cardSort changes this._disposers.sort = reaction( () => GPTPopup.Instance.visible, isVis => { @@ -135,8 +114,7 @@ export class CollectionCardView extends CollectionSubView() { } /** - * The child documents to be rendered-- either all of them except the Links or the docs in the currently active - * custom group + * The child documents to be rendered-- everything other than ink/link docs (which are marks as being svg's) */ @computed get childDocsWithoutLinks() { return this.childDocs.filter(l => !l.layout_isSvg); @@ -155,8 +133,7 @@ export class CollectionCardView extends CollectionSubView() { */ quizMode = () => { const randomIndex = Math.floor(Math.random() * this.childDocs.length); - SelectionManager.DeselectAll(); - DocumentView.SelectView(DocumentView.getDocumentView(this.childDocs[randomIndex]), false); + DocumentView.getDocumentView(this.childDocs[randomIndex])?.select(false); }; /** @@ -168,29 +145,15 @@ export class CollectionCardView extends CollectionSubView() { @action setHoveredNodeIndex = (index: number) => { - if (!DocumentView.SelectedDocs().includes(this.childDocs[index])) { - this._hoveredNodeIndex = index; - } + if (!SnappingManager.IsDragging) this._hoveredNodeIndex = index; }; - /** - * Translates the hovered node to the center of the screen - * @param index - * @returns - */ - translateHover = (index: number) => (this._hoveredNodeIndex === index && !DocumentView.SelectedDocs().includes(this.childDocs[index]) ? -50 : 0); - - isSelected = (index: number) => DocumentView.SelectedDocs().includes(this.childDocs[index]); - - /** - * Returns all the documents except the one that's currently selected - */ - inactiveDocs = () => this.childDocsWithoutLinks.filter(d => !DocumentView.SelectedDocs().includes(d)); + isSelected = (doc: Doc) => this._docRefs.get(doc)?.IsSelected; childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, this._props.PanelWidth() / 2); childPanelHeight = () => this._props.PanelHeight() * this.fitContentScale; onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick); - isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this._props.isAnyChildContentActive(); - isChildContentActive = () => !!this.isContentActive(); + isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this.isAnyChildContentActive(); + isAnyChildContentActive = this._props.isAnyChildContentActive; /** * Returns the degree to rotate a card dependind on the amount of cards in their row and their index in said row @@ -202,13 +165,14 @@ export class CollectionCardView extends CollectionSubView() { 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))); - - if (amCards % 2 === 0 && possRotate === 0) { - return possRotate + Math.abs(-30 + (index - 1) * (30 / (amCards / 2))); - } - if (amCards % 2 === 0 && index > (amCards + 1) / 2) { - return possRotate + stepMag; + if (amCards % 2 === 0) { + if (possRotate === 0) { + return possRotate + Math.abs(-30 + (index - 1) * (30 / (amCards / 2))); + } + if (index > (amCards + 1) / 2) { + const stepMag = Math.abs(-30 + (amCards / 2 - 1) * (30 / ((amCards - (amCards % 2)) / 2))); + return possRotate + stepMag; + } } return possRotate; @@ -274,22 +238,13 @@ export class CollectionCardView extends CollectionSubView() { /** * Checks to see if a card is being dragged and calls the appropriate methods if so - * @param e the current pointer event */ - @action onPointerMove = (x: number, y: number) => { this._docDraggedIndex = DragManager.docsBeingDragged.length ? this.findCardDropIndex(x, y) : -1; }; /** - * Handles external drop of images/PDFs etc from outside Dash. - */ - onExternalDrop = async (e: React.DragEvent): Promise<void> => { - super.onExternalDrop(e, {}); - }; - - /** * Resets all the doc dragging vairables once a card is dropped * @param e * @param de drop event @@ -326,7 +281,7 @@ export class CollectionCardView extends CollectionSubView() { } /** - * Used to determine how to sort cards based on tags. The lestmost tags are given lower values while cards to the right are + * Used to determine how to sort cards based on tags. The leftmost 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 @@ -352,24 +307,14 @@ export class CollectionCardView extends CollectionSubView() { 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: { - const d1 = DashColor(StrCast(docA.backgroundColor)); - const d2 = DashColor(StrCast(docB.backgroundColor)); - return [d1.hsv().hue(), d2.hsv().hue()]; - } - 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)]; + default: + case cardSortings.Type: return [StrCast(docA.type), StrCast(docB.type)]; + case cardSortings.Chat: return [NumCast(docA.chatIndex, 9999), NumCast(docB.chatIndex,9999)]; + 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().hue(), DashColor(StrCast(docB.backgroundColor)).hsv().hue()]; } - })(); - - const out = typeA < typeB ? -1 : typeA > typeB ? 1 : 0; - return isDesc ? out : -out; + })(); //prettier-ignore + return (typeA < typeB ? -1 : typeA > typeB ? 1 : 0) * (isDesc ? 1 : -1); }); if (dragIndex !== -1) { const draggedDoc = DragManager.docsBeingDragged[0]; @@ -382,6 +327,15 @@ export class CollectionCardView extends CollectionSubView() { return docs; }; + isChildContentActive = () => + this._props.isContentActive?.() === false + ? false + : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive)) + ? true + : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false + ? false + : undefined; + displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => ( <DocumentView {...this._props} @@ -396,13 +350,14 @@ export class CollectionCardView extends CollectionSubView() { LayoutTemplateString={this._props.childLayoutString} containerViewPath={this.childContainerViewPath} ScreenToLocalTransform={screenToLocalTransform} // makes sure the box wrapper thing is in the right spot - isContentActive={emptyFunction} isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive} PanelWidth={this.childPanelWidth} PanelHeight={this.childPanelHeight} dontCenter="y" // Don't center it vertically, because the grid it's in is already doing that and we don't want to do it twice. dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType} showTags={BoolCast(this.layoutDoc.showChildTags)} + whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged} + isContentActive={this.isChildContentActive} dontHideOnDrag /> ); @@ -416,13 +371,11 @@ export class CollectionCardView extends CollectionSubView() { if (this.sortedDocs.length < this._maxRowCount) { return this.sortedDocs.length; } - // 13 - 3 = 10 const totalCards = this.sortedDocs.length; // if 9 or less if (index < totalCards - (totalCards % this._maxRowCount)) { return this._maxRowCount; } - // (3) return totalCards % this._maxRowCount; }; /** @@ -441,19 +394,18 @@ export class CollectionCardView extends CollectionSubView() { translateOverflowX = (realIndex: number, calcRowCards: number) => (realIndex < this._maxRowCount ? 0 : (this._maxRowCount - calcRowCards) * (this.childPanelWidth() / 2)); /** - * Determines how far to translate a card in the y direction depending on its index, whether or not its being hovered, or if it's selected - * @param isHovered - * @param isSelected - * @param realIndex - * @param amCards - * @param calcRowIndex - * @returns + * Determines how far to translate a card in the y direction depending on its index and if it's selected + * @param isActive whether the card is focused for interaction + * @param realIndex index of card from start of deck + * @param amCards ?? + * @param calcRowIndex index of card from start of row + * @returns Y translation of card */ - calculateTranslateY = (isHovered: boolean, isSelected: boolean, realIndex: number, amCards: number, calcRowIndex: number) => { + calculateTranslateY = (isActive: boolean, realIndex: number, amCards: number, calcRowIndex: number) => { 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 (isActive) return rowToCenterShift * rowHeight - rowHeight / 2; if (amCards == 1) return 50 * this.fitContentScale; return this.translateY(amCards, calcRowIndex, realIndex); }; @@ -493,7 +445,7 @@ export class CollectionCardView extends CollectionSubView() { const hrefParts = href.split('.'); const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; try { - const hrefBase64 = await CollectionCardView.imageUrlToBase64(hrefComplete); + const hrefBase64 = await imageUrlToBase64(hrefComplete); const response = await gptImageLabel(hrefBase64, 'Give three to five labels to describe this image.'); image[DocData].description = response.trim(); return response; // Return the response from gptImageLabel @@ -576,15 +528,43 @@ export class CollectionCardView extends CollectionSubView() { await this.childPairStringListAndUpdateSortDesc(); }; + childScreenToLocal = computedFn((doc: Doc, index: number, calcRowIndex: number, isSelected: boolean, amCards: number) => () => { + // need to explicitly trigger an invalidation since we're reading everything from the Dom + this._forceChildXf; + this._props.ScreenToLocalTransform(); + + const dref = this._docRefs.get(doc); + const { translateX, translateY, scale } = ClientUtils.GetScreenTransform(dref?.ContentDiv); + if (!scale) return new Transform(0, 0, 0); + + return new Transform(-translateX + (dref?.centeringX || 0) * scale, + -translateY + (dref?.centeringY || 0) * scale, 1) + .scale(1 / scale).rotate(!isSelected ? -this.rotate(amCards, calcRowIndex) : 0); // prettier-ignore + }); + + cardPointerUp = action((doc: Doc) => { + // if a card doc has just moved, or a card is selected and in front, then ignore this event + if (this.isSelected(doc) || this._dropped) { + this._dropped = false; + } else { + // otherwise, turn off documentDecorations becase we're in a selection transition and want to avoid artifacts. + // Turn them back on when the animation has completed and the render and backend structures are in synch + SnappingManager.SetIsResizing(doc[Id]); + setTimeout( + action(() => { + SnappingManager.SetIsResizing(undefined); + this._forceChildXf++; + }), + 1000 + ); + } + }); + /** * Actually renders all the cards */ @computed get renderCards() { - const sortedDocs = this.sortedDocs; - const anySelected = this.childDocs.some(doc => DocumentView.SelectedDocs().includes(doc)); - const isEmpty = this.childDocsWithoutLinks.length === 0; - - if (isEmpty) { + if (!this.childDocsWithoutLinks.length) { 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 @@ -593,31 +573,17 @@ export class CollectionCardView extends CollectionSubView() { } // Map sorted documents to their rendered components - return sortedDocs.map((doc, index) => { - const realIndex = sortedDocs.indexOf(doc); - const calcRowIndex = this.overflowIndexCalc(realIndex); - const amCards = this.overflowAmCardsCalc(realIndex); + return this.sortedDocs.map((doc, index) => { + const calcRowIndex = this.overflowIndexCalc(index); + const amCards = this.overflowAmCardsCalc(index); const view = DocumentView.getDocumentView(doc, this.DocumentView?.()); - const isSelected = view?.ComponentView?.isAnyChildContentActive?.() || view?.IsSelected ? true : false; - - const childScreenToLocal = () => { - // need to explicitly trigger an invalidation since we're reading everything from the Dom - this._forceChildXf; - this._props.ScreenToLocalTransform(); - const dref = this._docRefs.get(doc); - const { translateX, translateY, scale } = ClientUtils.GetScreenTransform(dref?.ContentDiv); - if (!scale) return new Transform(0, 0, 0); - - return new Transform(-translateX + (dref?.centeringX || 0) * scale, - -translateY + (dref?.centeringY || 0) * scale, 1) - .scale(1 / scale).rotate(!isSelected ? -this.rotate(amCards, calcRowIndex) : 0); // prettier-ignore - }; + const childScreenToLocal = this.childScreenToLocal(doc, index, calcRowIndex, !!view?.IsContentActive, amCards); const translateIfSelected = () => { const indexInRow = index % this._maxRowCount; const rowIndex = Math.trunc(index / this._maxRowCount); - const rowCenterIndex = Math.min(this._maxRowCount, sortedDocs.length - rowIndex * this._maxRowCount) / 2; + const rowCenterIndex = Math.min(this._maxRowCount, this.sortedDocs.length - rowIndex * this._maxRowCount) / 2; return (rowCenterIndex - indexInRow) * 100 - 50; }; const aspect = NumCast(doc.height) / NumCast(doc.width, 1); @@ -627,33 +593,18 @@ export class CollectionCardView extends CollectionSubView() { return ( <div key={doc[Id]} - className={`card-item${isSelected ? '-active' : anySelected ? '-inactive' : ''}`} - onPointerUp={action(() => { - // if a card doc has just moved, or a card is selected and in front, then ignore this event - if (DocumentView.SelectedDocs().includes(doc) || this._dropped) { - this._dropped = false; - } else { - // otherwise, turn off documentDecorations becase we're in a selection transition and want to avoid artifacts. - // Turn them back on when the animation has completed and the render and backend structures are in synch - SnappingManager.SetIsResizing(doc[Id]); - setTimeout( - action(() => { - SnappingManager.SetIsResizing(undefined); - this._forceChildXf++; - }), - 1000 - ); - } - })} + className={`card-item${view?.IsContentActive ? '-active' : this.isAnyChildContentActive() ? '-inactive' : ''}`} + onPointerUp={() => this.cardPointerUp(doc)} style={{ width: this.childPanelWidth(), height: 'max-content', - transform: `translateY(${this.calculateTranslateY(this._hoveredNodeIndex === index, isSelected, realIndex, amCards, calcRowIndex)}px) - translateX(calc(${isSelected ? translateIfSelected() : 0}% + ${this.translateOverflowX(realIndex, amCards)}px)) - rotate(${!isSelected ? this.rotate(amCards, calcRowIndex) : 0}deg) - scale(${isSelected ? `${Math.min(hscale, vscale) * 100}%` : this._hoveredNodeIndex === index ? 1.05 : 1})`, + transform: `translateY(${this.calculateTranslateY(!!view?.IsContentActive, index, amCards, calcRowIndex)}px) + translateX(calc(${view?.IsContentActive ? translateIfSelected() : 0}% + ${this.translateOverflowX(index, amCards)}px)) + rotate(${!view?.IsContentActive ? this.rotate(amCards, calcRowIndex) : 0}deg) + scale(${view?.IsContentActive ? `${Math.min(hscale, vscale) * 100}%` : this._hoveredNodeIndex === index ? 1.1 : 1})`, }} // prettier-ignore - onPointerEnter={() => !SnappingManager.IsDragging && this.setHoveredNodeIndex(index)}> + onPointerEnter={() => this.setHoveredNodeIndex(index)} + onPointerLeave={() => this.setHoveredNodeIndex(-1)}> {this.displayDoc(doc, childScreenToLocal)} </div> ); @@ -680,8 +631,7 @@ export class CollectionCardView extends CollectionSubView() { ...(!isEmpty && { transform: `scale(${1 / this.fitContentScale})` }), ...(!isEmpty && { height: `${100 * this.fitContentScale}%` }), gridAutoRows: `${100 / this.numRows}%`, - }} - onMouseLeave={() => this.setHoveredNodeIndex(-1)}> + }}> {this.renderCards} </div> </div> diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index d1304b8f4..e1786d2c9 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -31,17 +31,17 @@ import { ScriptingRepl } from '../ScriptingRepl'; import { UndoStack } from '../UndoStack'; import './CollectionDockingView.scss'; import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; -import { TabHTMLElement } from './TabDocView'; +import { TabDocView, TabHTMLElement } from './TabDocView'; @observer export class CollectionDockingView extends CollectionSubView() { - static tabClass: unknown = null; + static tabClass?: typeof TabDocView; /** * Initialize by assigning the add split method to DocumentView and by * configuring golden layout to render its documents using the specified React component * @param ele - typically would be set to TabDocView */ - public static Init(ele: unknown) { + public static Init(ele: typeof TabDocView) { this.tabClass = ele; DocumentView.addSplit = CollectionDockingView.AddSplit; } @@ -544,7 +544,7 @@ export class CollectionDockingView extends CollectionSubView() { tabCreated = (tab: { contentItem: { element: HTMLElement[] } }) => { this.tabMap.add(tab); // InitTab is added to the tab's HTMLElement in TabDocView - const tabdocviewContent = tab.contentItem.element[0]?.firstChild?.firstChild as unknown as TabHTMLElement; + const tabdocviewContent = tab.contentItem.element[0]?.firstChild?.firstChild as TabHTMLElement; tabdocviewContent?.InitTab?.(tab); // have to explicitly initialize tabs that reuse contents from previous tabs (ie, when dragging a tab around a new tab is created for the old content) }; diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 5d32482c3..581201a20 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -296,7 +296,7 @@ export function CollectionSubView<X>() { return false; } - protected async onExternalDrop(e: React.DragEvent, options: DocumentOptions, completed?: (docs: Doc[]) => void) { + protected async onExternalDrop(e: React.DragEvent, options: DocumentOptions = {}, completed?: (docs: Doc[]) => void) { if (e.ctrlKey) { e.stopPropagation(); // bcz: this is a hack to stop propagation when dropping an image on a text document with shift+ctrl return; diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index c9e934448..7418d4360 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/jsx-props-no-spreading */ import { IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -16,7 +15,6 @@ import { ContextMenuProps } from '../ContextMenuItem'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { FieldView } from '../nodes/FieldView'; import { OpenWhere } from '../nodes/OpenWhere'; -import { CollectionCalendarView } from './CollectionCalendarView'; import { CollectionCardView } from './CollectionCardDeckView'; import { CollectionCarousel3DView } from './CollectionCarousel3DView'; import { CollectionCarouselView } from './CollectionCarouselView'; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx index c17371151..51add85a8 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx @@ -46,7 +46,6 @@ export function InfoState( gif?: string, entryFunc?: () => unknown ) { - // eslint-disable-next-line new-cap return new infoState(msg, arcs, gif, entryFunc); } diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx index 753685b97..583f2e656 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx @@ -6,6 +6,7 @@ import 'ldrs/ring'; import { action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import React from 'react'; +import { imageUrlToBase64 } from '../../../../ClientUtils'; import { Utils, numberRange } from '../../../../Utils'; import { Doc, NumListCast, Opt } from '../../../../fields/Doc'; import { DocData } from '../../../../fields/DocSymbols'; @@ -22,13 +23,13 @@ import { MainView } from '../../MainView'; import { DocumentView } from '../../nodes/DocumentView'; import { FieldView, FieldViewProps } from '../../nodes/FieldView'; import { OpenWhere } from '../../nodes/OpenWhere'; -import { CollectionCardView } from '../CollectionCardDeckView'; import './ImageLabelBox.scss'; import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; export class ImageInformationItem {} export class ImageLabelBoxData { + // eslint-disable-next-line no-use-before-define static _instance: ImageLabelBoxData; @observable _docs: Doc[] = []; @observable _labelGroups: string[] = []; @@ -47,8 +48,8 @@ export class ImageLabelBoxData { }; @action - addLabel = (label: string) => { - label = label.toUpperCase().trim(); + addLabel = (labelIn: string) => { + const label = labelIn.toUpperCase().trim(); if (label.length > 0) { if (!this._labelGroups.includes(label)) { this._labelGroups = [...this._labelGroups, label.startsWith('#') ? label : '#' + label]; @@ -68,9 +69,10 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ImageLabelBox, fieldKey); } + // eslint-disable-next-line no-use-before-define + public static Instance: ImageLabelBox; private _dropDisposer?: DragManager.DragDropDisposer; - public static Instance: ImageLabelBox; private _inputRef = React.createRef<HTMLInputElement>(); @observable _loading: boolean = false; private _currentLabel: string = ''; @@ -99,7 +101,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { } @observable _displayImageInformation: boolean = false; - constructor(props: any) { + constructor(props: FieldViewProps) { super(props); makeObservable(this); ring.register(); @@ -165,7 +167,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { const imageInfos = this._selectedImages.map(async doc => { if (!doc[DocData].tags_chat) { const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.'); - return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 => + return imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 => !hrefBase64 ? undefined : gptImageLabel(hrefBase64,'Give three labels to describe this image.').then(labels => ({ doc, labels }))) ; // prettier-ignore @@ -178,13 +180,13 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { const labels = imageInfo.labels.split('\n'); labels.forEach(label => { - label = + const hashLabel = '#' + label .replace(/^\d+\.\s*|-|f\*/, '') .replace(/^#/, '') .trim(); - (imageInfo.doc[DocData].tags_chat as List<string>).push(label); + (imageInfo.doc[DocData].tags_chat as List<string>).push(hashLabel); }); } }); @@ -214,7 +216,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { // most similar one. this._selectedImages.forEach(doc => { const embedLists = numberRange((doc[DocData].tags_chat as List<string>).length).map(n => Array.from(NumListCast(doc[DocData][`tags_embedding_${n + 1}`]))); - const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && similarity(Array.from(embedding), l)!) || 0)); + const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map(l => (embedding && similarity(Array.from(embedding), l)!) || 0)); const {label: mostSimilarLabelCollect} = this._labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) })) .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur, @@ -243,7 +245,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { if (this._selectedImages.length === 0) { return ( <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele!)}> - <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the 'Classify and Sort Images' button. Then, add the desired groups for the images to be put in.</p> + <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the 'Classify and Sort Images' button. Then, add the desired groups for the images to be put in.</p> </div> ); } diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts index 423a2d6ef..2b8908899 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -4,6 +4,7 @@ import { runInAction } from 'mobx'; import { Doc, DocListCast, Opt, StrListCast } 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'; @@ -16,7 +17,6 @@ import { UndoManager, undoable } from '../../util/UndoManager'; import { GestureOverlay } from '../GestureOverlay'; import { InkTranscription } from '../InkTranscription'; import { InkingStroke } from '../InkingStroke'; -import { MainView } from '../MainView'; import { PropertiesView } from '../PropertiesView'; import { CollectionFreeFormView } from '../collections/collectionFreeForm'; import { CollectionFreeFormDocumentView } from '../nodes/CollectionFreeFormDocumentView'; @@ -40,6 +40,7 @@ import { VideoBox } from '../nodes/VideoBox'; import { WebBox } from '../nodes/WebBox'; import { RichTextMenu } from '../nodes/formattedText/RichTextMenu'; import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup'; +import { OpenWhere } from '../nodes/OpenWhere'; // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function IsNoneSelected() { @@ -240,31 +241,15 @@ ScriptingGlobals.add(function showFreeform( ['pile', { checkResult: (doc: Doc) => doc._type_collection == CollectionViewType.Freeform, setDoc: (doc: Doc, dv: DocumentView) => { - doc._type_collection = CollectionViewType.Freeform; const newCol = Docs.Create.CarouselDocument(DocListCast(doc[Doc.LayoutFieldKey(doc)]), { + title: doc.title + "_carousel", _width: 250, _height: 200, _layout_fitWidth: false, _layout_autoHeight: true, + childFilters: new List<string>(StrListCast(doc.childFilters)) }); - - - const iconMap: { [key: number]: string } = { - 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 }) - + dv._props.addDocTab?.(newCol, OpenWhere.addRight); }, }], ]); @@ -300,7 +285,7 @@ ScriptingGlobals.add(function setTagFilter(tag: string, added: boolean, checkRes added ? Doc.setDocFilter(selected, 'tags', tag, 'check') : Doc.setDocFilter(selected, 'tags', tag, 'remove'); } else { SnappingManager.PropertiesWidth < 5 && SnappingManager.SetPropertiesWidth(0); - SnappingManager.SetPropertiesWidth(MainView.Instance.propertiesWidth() < 15 ? 250 : 0); + SnappingManager.SetPropertiesWidth(SnappingManager.PropertiesWidth < 15 ? 250 : 0); PropertiesView.Instance?.CloseAll(); runInAction(() => (PropertiesView.Instance.openFilters = SnappingManager.PropertiesWidth > 5)); } diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx index e686368e9..ef66c2b11 100644 --- a/src/client/views/nodes/ComparisonBox.tsx +++ b/src/client/views/nodes/ComparisonBox.tsx @@ -5,7 +5,7 @@ import { IReactionDisposer, action, computed, makeObservable, observable, reacti import { observer } from 'mobx-react'; import * as React from 'react'; import ReactLoading from 'react-loading'; -import { returnFalse, returnNone, returnTrue, returnZero, setupMoveUpEvents } from '../../../ClientUtils'; +import { imageUrlToBase64, returnFalse, returnNone, returnTrue, returnZero, setupMoveUpEvents } from '../../../ClientUtils'; import { emptyFunction } from '../../../Utils'; import { Doc, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; @@ -44,7 +44,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() constructor(props: FieldViewProps) { super(props); makeObservable(this); - this.setListening(); } @observable private _inputValue = ''; @@ -366,7 +365,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() 'Content-Type': 'application/json', }, }); - this.Document.phoneticTranscription = response.data['transcription']; + this.Document.phoneticTranscription = response.data.transcription; }; /** @@ -375,7 +374,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() * @returns */ getYouTubeVideoId = (url: string) => { - const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=|\?v=)([^#\&\?]*).*/; + const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=|\?v=)([^#&?]*).*/; const match = url.match(regExp); return match && match[2].length === 11 ? match[2] : null; }; @@ -393,7 +392,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() 'Content-Type': 'application/json', }, }); - return response.data['transcription']; + return response.data.transcription; }; createFlashcardPile(collectionArr: Doc[], gpt: boolean) { @@ -403,9 +402,9 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() _layout_fitWidth: false, _layout_autoHeight: true, }); - newCol['x'] = this.layoutDoc['x']; - newCol['y'] = NumCast(this.layoutDoc['y']) + 50; - newCol.type_collection = 'carousel'; + newCol.x = this.layoutDoc.x; + newCol.y = NumCast(this.layoutDoc.y) + 50; + newCol.type_collection = CollectionViewType.Carousel as string; for (let i = 0; i < collectionArr.length; i++) { DocCast(collectionArr[i])[DocData].embedContainer = newCol; } @@ -600,34 +599,17 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() _height: 150, title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-', }); - imageSnapshot['x'] = this.layoutDoc['x']; - imageSnapshot['y'] = this.layoutDoc['y']; + imageSnapshot.x = this.layoutDoc.x; + imageSnapshot.y = this.layoutDoc.y; return imageSnapshot; } catch (error) { console.log(error); } }; - static imageUrlToBase64 = async (imageUrl: string): Promise<string> => { - try { - const response = await fetch(imageUrl); - const blob = await response.blob(); - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = error => reject(error); - }); - } catch (error) { - console.error('Error:', error); - throw error; - } - }; - getImageDesc = async (u: string) => { try { - const hrefBase64 = await ComparisonBox.imageUrlToBase64(u); + const hrefBase64 = await imageUrlToBase64(u); const response = await gptImageLabel(hrefBase64, 'Answer the following question as a short flashcard response. Do not include a label.' + (this.dataDoc.text as RichTextField)?.Text); DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = response; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index df6e74d85..3dd568fda 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/jsx-props-no-spreading */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Checkbox } from '@mui/material'; import { Colors, Toggle, ToggleType, Type } from 'browndash-components'; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 23ec42610..04a31fd83 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -545,6 +545,9 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document return; } + const items = this._props.styleProvider?.(this.Document, this._props, StyleProp.ContextMenuItems) as ContextMenuProps[]; + items?.forEach(item => ContextMenu.Instance.addItem(item)); + const customScripts = Cast(this.Document.contextMenuScripts, listSpec(ScriptField), []); StrListCast(this.Document.contextMenuLabels).forEach((label, i) => cm.addItem({ description: label, event: () => customScripts[i]?.script.run({ documentView: this, this: this.Document, scriptContext: this._props.scriptContext }), icon: 'sticky-note' }) @@ -1209,6 +1212,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { public set IsSelected(val) { runInAction(() => { this._selected = val; }); } // prettier-ignore public get IsSelected() { return this._selected; } // prettier-ignore + public get IsContentActive(){ return this._docViewInternal?.isContentActive(); } // prettier-ignore public get topMost() { return this._props.renderDepth === 0; } // prettier-ignore public get ContentDiv() { return this._docViewInternal?._contentDiv; } // prettier-ignore public get ComponentView() { return this._docViewInternal?._componentView; } // prettier-ignore diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index 7b652dded..170966471 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -6,7 +6,6 @@ import { DateField } from '../../../fields/DateField'; import { Doc, Field, FieldType, Opt } from '../../../fields/Doc'; import { List } from '../../../fields/List'; import { ScriptField } from '../../../fields/ScriptField'; -import { WebField } from '../../../fields/URLField'; import { dropActionType } from '../../util/DropActionTypes'; import { Transform } from '../../util/Transform'; import { PinProps } from '../PinFuncs'; @@ -14,9 +13,10 @@ import { ViewBoxInterface } from '../ViewBoxInterface'; import { DocumentView } from './DocumentView'; import { FocusViewOptions } from './FocusViewOptions'; import { OpenWhere } from './OpenWhere'; +import { WebField } from '../../../fields/URLField'; +import { ContextMenuProps } from '../ContextMenuItem'; export type FocusFuncType = (doc: Doc, options: FocusViewOptions) => Opt<number>; -// eslint-disable-next-line no-use-before-define export type StyleProviderFuncType = ( doc: Opt<Doc>, // eslint-disable-next-line no-use-before-define @@ -24,6 +24,7 @@ export type StyleProviderFuncType = ( property: string ) => | Opt<FieldType> + | ContextMenuProps[] | { clipPath: string; jsx: JSX.Element } | JSX.Element | JSX.IntrinsicElements diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 4d199b360..3ffda5a35 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -139,44 +139,3 @@ .imageBox-fadeBlocker-hover { opacity: 0; } - -.loading-spinner { - position: absolute; - display: flex; - justify-content: center; - align-items: center; - height: 100%; - width: 100%; - // left: 50%; - // top: 50%; - z-index: 200; - font-size: 20px; - font-weight: bold; - color: #17175e; -} - -.check-icon { - position: absolute; - right: 40; - bottom: 10; - color: green; - display: inline-block; - font-size: 20px; - overflow: hidden; -} - -.redo-icon { - position: absolute; - right: 10; - bottom: 10; - color: black; - display: inline-block; - font-size: 20px; - overflow: hidden; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 0571351f0..b384e0059 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -17,13 +17,11 @@ import { Cast, ImageCast, NumCast, RTFCast, StrCast } from '../../../fields/Type import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; -import { gptAPICall, GPTCallType, gptImageLabel } from '../../apis/gpt/GPT'; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { DocUtils, FollowLinkScript } from '../../documents/DocUtils'; import { Networking } from '../../Network'; import { DragManager } from '../../util/DragManager'; -import { dropActionType } from '../../util/DropActionTypes'; import { SnappingManager } from '../../util/SnappingManager'; import { undoable, undoBatch } from '../../util/UndoManager'; import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; @@ -38,16 +36,8 @@ import { StyleProp } from '../StyleProp'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; -import { ImageUtility } from './generativeFill/generativeFillUtils/ImageHandler'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; -// import stringSimilarity from 'string-similarity'; - -enum quizMode { - SMART = 'smart', - NORMAL = 'normal', - NONE = 'none', -} export class ImageEditorData { // eslint-disable-next-line no-use-before-define @@ -79,25 +69,24 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ImageBox, fieldKey); } + _ffref = React.createRef<CollectionFreeFormView>(); private _ignoreScroll = false; private _forcedScroll = false; private _dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [name: string]: IReactionDisposer } = {}; private _getAnchor: (savedAnnotations: Opt<ObservableMap<number, HTMLDivElement[]>>, addAsAnnotation: boolean) => Opt<Doc> = () => undefined; private _overlayIconRef = React.createRef<HTMLDivElement>(); - private _marqueeref = React.createRef<MarqueeAnnotator>(); private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); - private _imageRef: HTMLImageElement | null = null; // <video> ref - @observable private _quizBoxes: Doc[] = []; + imageRef: HTMLImageElement | null = null; // <video> ref + marqueeref = React.createRef<MarqueeAnnotator>(); + @observable Loading = false; // bcz: this should be migrated into StylProviderQuiz since it's not fundamental to the imageBox + @observable private _searchInput = ''; - @observable private _quizMode = quizMode.NONE; @observable private _savedAnnotations = new ObservableMap<number, (HTMLDivElement & { marqueeing?: boolean })[]>(); @observable private _curSuffix = ''; @observable private _error = ''; - @observable private _loading = false; @observable private _isHovering = false; // flag to switch between primary and alternate images on hover - _ffref = React.createRef<CollectionFreeFormView>(); constructor(props: FieldViewProps) { super(props); @@ -109,7 +98,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this._dropDisposer?.(); ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document)); }; - getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor const anchor = @@ -314,331 +302,10 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return cropping; }; - createCanvas = async () => { - const canvas = document.createElement('canvas'); - const scaling = 1 / (this._props.NativeDimScaling?.() || 1); - const w = AnchorMenu.Instance.marqueeWidth * scaling; - const h = AnchorMenu.Instance.marqueeHeight * scaling; - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext('2d'); // draw image to canvas. scale to target dimensions - if (ctx) { - this._imageRef && ctx.drawImage(this._imageRef, NumCast(this._marqueeref.current?.left) * scaling, NumCast(this._marqueeref.current?.top) * scaling, w, h, 0, 0, w, h); - } - const blob = await ImageUtility.canvasToBlob(canvas); - return ImageBox.selectUrlToBase64(blob); - }; - - createSnapshotLink = (imagePath: string, downX?: number, downY?: number) => { - const url = !imagePath.startsWith('/') ? ClientUtils.CorsProxy(imagePath) : imagePath; - const width = NumCast(this.layoutDoc._width) || 1; - const height = NumCast(this.layoutDoc._height); - const imageSnapshot = Docs.Create.ImageDocument(url, { - _nativeWidth: Doc.NativeWidth(this.layoutDoc), - _nativeHeight: Doc.NativeHeight(this.layoutDoc), - x: NumCast(this.layoutDoc.x) + width, - y: NumCast(this.layoutDoc.y), - onClick: FollowLinkScript(), - _width: 150, - _height: (height / width) * 150, - title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-', - }); - Doc.SetNativeWidth(imageSnapshot[DocData], Doc.NativeWidth(this.layoutDoc)); - Doc.SetNativeHeight(imageSnapshot[DocData], Doc.NativeHeight(this.layoutDoc)); - this._props.addDocument?.(imageSnapshot); - DocUtils.MakeLink(imageSnapshot, this.getAnchor(true), { link_relationship: 'video snapshot' }); - setTimeout(() => downX !== undefined && downY !== undefined && DocumentView.getFirstDocumentView(imageSnapshot)?.startDragging(downX, downY, dropActionType.move, true)); - }; - - static selectUrlToBase64 = async (blob: Blob): Promise<string> => { - try { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = error => reject(error); - }); - } catch (error) { - console.error('Error:', error); - throw error; - } - }; - - /** - * Calls backend to find any text on an image. Gets the text and the - * coordinates of the text and creates label boxes at those locations. - * @param quiz - * @param i - */ - pushInfo = async (quiz: quizMode, i?: string) => { - this._quizMode = quiz; - this._loading = true; - - const img = { - file: i ? i : this.paths[0], - drag: i ? 'drag' : 'full', - smart: quiz, - }; - const response = await axios.post('http://localhost:105/labels/', img, { - headers: { - 'Content-Type': 'application/json', - }, - }); - if (response.data['boxes'].length != 0) { - this.createBoxes(response.data['boxes'], response.data['text']); - } else { - this._loading = false; - } - }; - - /** - * Creates label boxes over text on the image to be filled in. - * @param boxes - * @param texts - */ - createBoxes = (boxes: [[[number, number]]], texts: [string]) => { - for (let i = 0; i < boxes.length; i++) { - const coords = boxes[i] ? boxes[i] : []; - const width = coords[1][0] - coords[0][0]; - const height = coords[2][1] - coords[0][1]; - const text = texts[i]; - - const newCol = Docs.Create.LabelDocument({ - _width: width, - _height: height, - _layout_fitWidth: true, - title: '', - }); - const scaling = 1 / (this._props.NativeDimScaling?.() || 1); - newCol.x = coords[0][0] + NumCast(this._marqueeref.current?.left) * scaling; - newCol.y = coords[0][1] + NumCast(this._marqueeref.current?.top) * scaling; - - newCol.zIndex = 1000; - newCol.forceActive = true; - newCol.quiz = text; - newCol.showQuiz = false; - newCol[DocData].textTransform = 'none'; - this._quizBoxes.push(newCol); - this.addDocument(newCol); - this._loading = false; - } - }; - - /** - * Create flashcards from an image. - */ - getImageDesc = async () => { - this._loading = true; - try { - const hrefBase64 = await this.createCanvas(); - const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this image with each question and answer labeled as "question" and "answer". Do not label each flashcard and do not include asterisks: '); - AnchorMenu.Instance.transferToFlashcard(response, NumCast(this.layoutDoc['x']), NumCast(this.layoutDoc['y'])); - } catch (error) { - console.log('Error', error); - } - this._loading = false; - }; - - /** - * Calls the createCanvas and pushInfo methods to convert the - * image to a form that can be passed to GPT and find the locations - * of the text. - */ - makeLabels = async () => { - try { - const hrefBase64 = await this.createCanvas(); - this.pushInfo(quizMode.NORMAL, hrefBase64); - } catch (error) { - console.log('Error', error); - } - }; - - /** - * Determines whether two words should be considered - * the same, allowing minor typos. - * @param str1 - * @param str2 - * @returns - */ - levenshteinDistance = (str1: string, str2: string) => { - const len1 = str1.length; - const len2 = str2.length; - const dp = Array.from(Array(len1 + 1), () => Array(len2 + 1).fill(0)); - - if (len1 === 0) return len2; - if (len2 === 0) return len1; - - for (let i = 0; i <= len1; i++) dp[i][0] = i; - for (let j = 0; j <= len2; j++) dp[0][j] = j; - - for (let i = 1; i <= len1; i++) { - for (let j = 1; j <= len2; j++) { - const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; - dp[i][j] = Math.min( - dp[i - 1][j] + 1, // deletion - dp[i][j - 1] + 1, // insertion - dp[i - 1][j - 1] + cost // substitution - ); - } - } - - return dp[len1][len2]; - }; - - /** - * Different algorithm for determining string similarity. - * @param str1 - * @param str2 - * @returns - */ - jaccardSimilarity = (str1: string, str2: string) => { - const set1 = new Set(str1.split(' ')); - const set2 = new Set(str2.split(' ')); - - const intersection = new Set([...set1].filter(x => set2.has(x))); - const union = new Set([...set1, ...set2]); - - return intersection.size / union.size; - }; - - /** - * Averages the jaccardSimilarity and levenshteinDistance scores - * to determine string similarity for the labelboxes answers and - * the users response. - * @param str1 - * @param str2 - * @returns - */ - stringSimilarity(str1: string, str2: string) { - const levenshteinDist = this.levenshteinDistance(str1, str2); - const levenshteinScore = 1 - levenshteinDist / Math.max(str1.length, str2.length); - - const jaccardScore = this.jaccardSimilarity(str1, str2); - - // Combine the scores with a higher weight on Jaccard similarity - return 0.5 * levenshteinScore + 0.5 * jaccardScore; - } - - @computed get checkIcon() { - return ( - <Tooltip title={<div className="dash-tooltip">Check</div>}> - <div className="check-icon" onPointerDown={this.check}> - <FontAwesomeIcon icon="circle-check" size="lg" /> - </div> - </Tooltip> - ); - } - - @computed get redoIcon() { - return ( - <Tooltip title={<div className="dash-tooltip">Redo</div>}> - <div className="redo-icon" onPointerDown={this.redo}> - <FontAwesomeIcon icon="redo-alt" size="lg" /> - </div> - </Tooltip> - ); - } - - /** - * Returns whether two strings are similar - * @param input - * @param target - * @returns - */ - compareWords = (input: string, target: string) => { - const distance = this.stringSimilarity(input.toLowerCase(), target.toLowerCase()); - return distance >= 0.7; - }; - - /** - * GPT returns a hex color for what color the label box should be based on - * the correctness of the users answer. - * @param inputString - * @returns - */ - extractHexAndSentences = (inputString: string) => { - // Regular expression to match a hexadecimal number at the beginning followed by a period and sentences - const regex = /^#([0-9A-Fa-f]+)\.\s*(.+)$/s; - const match = inputString.match(regex); - - if (match) { - const hexNumber = match[1]; - const sentences = match[2].trim(); - return { hexNumber, sentences }; - } else { - return { error: 'The input string does not match the expected format.' }; - } - }; - - /** - * Check whether the contents of the label boxes on an image are correct. - */ - check = () => { - this._loading = true; - this._quizBoxes.forEach(async doc => { - const input = StrCast(doc[DocData].title); - if (this._quizMode == quizMode.SMART && input) { - const questionText = 'Question: What was labeled in this image?'; - const rubricText = ' Rubric: ' + StrCast(doc.quiz); - const queryText = - questionText + - ' UserAnswer: ' + - input + - '. ' + - rubricText + - '. One sentence and evaluate based on meaning, not wording. Provide a hex color at the beginning with a period after it on a scale of green (minor details missed) to red (big error) for how correct the answer is. Example: "#FFFFFF. Pasta is delicious."'; - const response = await gptAPICall(queryText, GPTCallType.QUIZ); - const hexSent = this.extractHexAndSentences(response); - doc.quiz = hexSent.sentences?.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric'); - doc.backgroundColor = '#' + hexSent.hexNumber; - } else { - const match = this.compareWords(input, StrCast(doc.quiz)); - doc.backgroundColor = match ? '#11c249' : '#eb2d2d'; - } - doc.showQuiz = true; - }); - this._loading = false; - }; - - redo = () => { - this._quizBoxes.forEach(doc => { - doc[DocData].title = ''; - doc.backgroundColor = '#e4e4e4'; - doc.showQuiz = false; - }); - }; - - /** - * Get rid of all the label boxes on the images. - */ - exitQuizMode = () => { - this._quizMode = quizMode.NONE; - this._quizBoxes.forEach(doc => { - this.removeDocument?.(doc); - }); - this._quizBoxes = []; - }; - - @action - setRef = (iref: HTMLImageElement | null) => { - this._imageRef = iref; - }; - specificContextMenu = (): void => { const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (field) { const funcs: ContextMenuProps[] = []; - const quizes: ContextMenuProps[] = []; - quizes.push({ - description: 'Smart Check', - event: this._quizMode == quizMode.NONE ? () => this.pushInfo(quizMode.SMART) : this.exitQuizMode, - icon: 'pen-to-square', - }); - quizes.push({ - description: 'Normal', - event: this._quizMode == quizMode.NONE ? () => this.pushInfo(quizMode.NORMAL) : this.exitQuizMode, - icon: 'pencil', - }); funcs.push({ description: 'Rotate Clockwise 90', event: this.rotate, icon: 'redo-alt' }); funcs.push({ description: `Show ${this.layoutDoc._showFullRes ? 'Dynamic Res' : 'Full Res'}`, event: this.resolution, icon: 'expand' }); funcs.push({ description: 'Set Native Pixel Size', event: this.setNativeSize, icon: 'expand-arrows-alt' }); @@ -653,7 +320,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }), icon: 'pencil-alt', }); - ContextMenu.Instance?.addItem({ description: 'Quiz Mode', subitems: quizes, icon: 'file-pen' }); ContextMenu.Instance?.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' }); } }; @@ -769,7 +435,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { <div className="imageBox-fader" style={{ opacity: backAlpha }}> <img alt="" - ref={this.setRef} + ref={action((r: HTMLImageElement | null) => (this.imageRef = r))} key="paths" src={srcpath} style={{ transform, transformOrigin }} @@ -810,7 +476,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { e, action(moveEv => { MarqueeAnnotator.clearAnnotations(this._savedAnnotations); - this._marqueeref.current?.onInitiateSelection([moveEv.clientX, moveEv.clientY]); + this.marqueeref.current?.onInitiateSelection([moveEv.clientX, moveEv.clientY]); return true; }), returnFalse, @@ -822,12 +488,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @action finishMarquee = () => { this._getAnchor = AnchorMenu.Instance?.GetAnchor; - AnchorMenu.Instance.gptFlashcards = this.getImageDesc; + this._props.styleProvider?.(this.Document, this._props, StyleProp.AnchorMenuItems); AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument; - AnchorMenu.Instance.makeLabels = this.makeLabels; - AnchorMenu.Instance.marqueeWidth = this._marqueeref.current?.Width ?? 0; - AnchorMenu.Instance.marqueeHeight = this._marqueeref.current?.Height ?? 0; - this._marqueeref.current?.onTerminateSelection(); + AnchorMenu.Instance.marqueeWidth = this.marqueeref.current?.Width ?? 0; + AnchorMenu.Instance.marqueeHeight = this.marqueeref.current?.Height ?? 0; + this.marqueeref.current?.onTerminateSelection(); this._props.select(false); }; focus = (anchor: Doc, options: FocusViewOptions) => (anchor.type === DocumentType.CONFIG ? undefined : this._ffref.current?.focus(anchor, options)); @@ -888,7 +553,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { addDocument={this.addDocument}> {this.content} </CollectionFreeFormView> - {this._loading ? ( + {this.Loading ? ( <div className="loading-spinner" style={{ position: 'absolute' }}> <ReactLoading type="spin" height={50} width={50} color={'blue'} /> </div> @@ -897,7 +562,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { {!this._mainCont.current || !this.DocumentView || !this._annotationLayer.current ? null : ( <MarqueeAnnotator Document={this.Document} - ref={this._marqueeref} + ref={this.marqueeref} scrollTop={0} annotationLayerScrollTop={0} scaling={returnOne} @@ -914,8 +579,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // anchorMenuFlashcard={() => this.getImageDesc()} /> )} - {this._quizMode != quizMode.NONE ? this.checkIcon : null} - {this._quizMode != quizMode.NONE ? this.redoIcon : null} </div> ); } diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 058932457..696ba5697 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -1,12 +1,8 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Tooltip } from '@mui/material'; import { Property } from 'csstype'; -import { action, computed, makeObservable, observable } from 'mobx'; +import { action, computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import * as textfit from 'textfit'; -import { returnFalse, setupMoveUpEvents } from '../../../ClientUtils'; -import { emptyFunction } from '../../../Utils'; import { Field, FieldType } from '../../../fields/Doc'; import { BoolCast, NumCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; @@ -26,7 +22,6 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { } private dropDisposer?: DragManager.DragDropDisposer; private _timeout: NodeJS.Timeout | undefined; - @observable private _editLabel = false; _divRef: HTMLDivElement | null = null; constructor(props: FieldViewProps) { @@ -49,48 +44,6 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { return this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string; } - @computed get answerIcon() { - return ( - <Tooltip - title={ - <div className="answer-tooltip" style={{ minWidth: '150px' }}> - {StrCast(this.Document.quiz)} - </div> - }> - <div className="answer-tool-tip"> - <FontAwesomeIcon className="q-icon" icon="circle" color="white" /> - <FontAwesomeIcon className="answer-icon" icon="question" /> - </div> - </Tooltip> - ); - } - - @computed get editAnswer() { - return ( - <Tooltip - title={ - <div className="answer-tooltip" style={{ minWidth: '150px' }}> - {this._editLabel ? 'save' : 'edit correct answer'} - </div> - }> - <div className="answer-tool-tip" onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => this.editLabelAnswer())}> - <FontAwesomeIcon className="edit-icon" color={this._editLabel ? 'white' : 'black'} icon="pencil" size="sm" /> - </div> - </Tooltip> - ); - } - - editLabelAnswer = () => { - // when click the pencil, set the text to the quiz content. when click off, set the quiz text to that and set textbox to nothing. - if (!this._editLabel) { - this.dataDoc.title = StrCast(this.Document.quiz); - } else { - this.Document.quiz = this.Title; - this.dataDoc.title = ''; - } - this._editLabel = !this._editLabel; - }; - componentDidMount() { this._props.setContentViewBox?.(this); } @@ -98,8 +51,6 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { this._timeout && clearTimeout(this._timeout); } - specificContextMenu = (): void => {}; - drop = (/* e: Event, de: DragManager.DropEvent */) => { return false; }; @@ -152,7 +103,7 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { const boxParams = this.fitTextToBox(undefined); // this causes mobx to trigger re-render when data changes const label = this.Title.startsWith('#') ? null : this.Title; return ( - <div key={label?.length} className="labelBox-outerDiv" ref={this.createDropTarget} onContextMenu={this.specificContextMenu} style={{ boxShadow: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BoxShadow) as string }}> + <div key={label?.length} className="labelBox-outerDiv" ref={this.createDropTarget} style={{ boxShadow: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BoxShadow) as string }}> <div className="labelBox-mainButton" style={{ @@ -203,8 +154,6 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { {label} </div> </div> - {this.Document.showQuiz ? this.answerIcon : null} - {this.Document.showQuiz ? this.editAnswer : null} </div> ); } diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.tsx b/src/client/views/nodes/RecordingBox/ProgressBar.tsx index 62798bc2f..7e91df7ab 100644 --- a/src/client/views/nodes/RecordingBox/ProgressBar.tsx +++ b/src/client/views/nodes/RecordingBox/ProgressBar.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/no-array-index-key */ -/* eslint-disable react/require-default-props */ import * as React from 'react'; import { useEffect, useState, useRef } from 'react'; import './ProgressBar.scss'; diff --git a/src/client/views/nodes/calendarBox/CalendarBox.tsx b/src/client/views/nodes/calendarBox/CalendarBox.tsx index 678b7dd0b..d38cb5423 100644 --- a/src/client/views/nodes/calendarBox/CalendarBox.tsx +++ b/src/client/views/nodes/calendarBox/CalendarBox.tsx @@ -1,4 +1,4 @@ -import { Calendar, DateInput, EventClickArg, EventSourceInput } from '@fullcalendar/core'; +import { Calendar, EventClickArg, EventSourceInput } from '@fullcalendar/core'; import dayGridPlugin from '@fullcalendar/daygrid'; import multiMonthPlugin from '@fullcalendar/multimonth'; import timeGrid from '@fullcalendar/timegrid'; diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 865146d68..c89737e1e 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -13,7 +13,7 @@ import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transacti import { EditorView, NodeViewConstructor } from 'prosemirror-view'; import * as React from 'react'; import { BsMarkdownFill } from 'react-icons/bs'; -import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, returnFalse, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils'; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, imageUrlToBase64, returnFalse, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils'; import { DateField } from '../../../../fields/DateField'; import { CreateLinkToActiveAudio, Doc, DocListCast, Field, FieldType, Opt, StrListCast } from '../../../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DocCss, DocData, ForceServerWrite, UpdatingFromServer } from '../../../../fields/DocSymbols'; @@ -1008,30 +1008,13 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB // console.log('HI' + this.ProseRef?.getElementsByTagName('img')); }; - static imageUrlToBase64 = async (imageUrl: string): Promise<string> => { - try { - const response = await fetch(imageUrl); - const blob = await response.blob(); - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = error => reject(error); - }); - } catch (error) { - console.error('Error:', error); - throw error; - } - }; - getImageDesc = async (u: string) => { // if (StrCast(this.dataDoc.description)) return StrCast(this.dataDoc.description); // Return existing description // const { href } = (u as URLField).url; const hrefParts = u.split('.'); const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; try { - const hrefBase64 = await FormattedTextBox.imageUrlToBase64(u); + const hrefBase64 = await imageUrlToBase64(u); const response = await gptImageLabel( hrefBase64, 'Make flashcards out of this text and image with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' + (this.dataDoc.text as RichTextField)?.Text @@ -1270,7 +1253,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB }; @computed get tagsHeight() { - return this.DocumentView?.().showTags ? Math.max(0, 20 - Math.max(this._props.yPadding ?? 0, NumCast(this.layoutDoc._yMargin))) : 0; + return this.DocumentView?.().showTags ? Math.max(0, 20 - Math.max(this._props.yPadding ?? 0, NumCast(this.layoutDoc._yMargin))) * this.ScreenToLocalBoxXf().Scale : 0; } @computed get contentScaling() { diff --git a/src/client/views/smartdraw/SmartDrawHandler.tsx b/src/client/views/smartdraw/SmartDrawHandler.tsx index 75ef55060..b4635673c 100644 --- a/src/client/views/smartdraw/SmartDrawHandler.tsx +++ b/src/client/views/smartdraw/SmartDrawHandler.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { AiOutlineSend } from 'react-icons/ai'; import ReactLoading from 'react-loading'; import { INode, parse } from 'svgson'; +import { imageUrlToBase64 } from '../../../ClientUtils'; import { unimplementedFunction } from '../../../Utils'; import { Doc, DocListCast } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; @@ -19,7 +20,6 @@ import { undoable } from '../../util/UndoManager'; import { SVGToBezier, SVGType } from '../../util/bezierFit'; import { InkingStroke } from '../InkingStroke'; import { ObservableReactComponent } from '../ObservableReactComponent'; -import { CollectionCardView } from '../collections/CollectionCardDeckView'; import { MarqueeView } from '../collections/collectionFreeForm'; import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, DocumentView } from '../nodes/DocumentView'; import './SmartDrawHandler.scss'; @@ -310,7 +310,7 @@ export class SmartDrawHandler extends ObservableReactComponent<object> { const hrefParts = href.split('.'); const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; try { - const hrefBase64 = await CollectionCardView.imageUrlToBase64(hrefComplete); + const hrefBase64 = await imageUrlToBase64(hrefComplete); const strokes = DocListCast(drawing[DocData].data); const coords: string[] = []; strokes.forEach((stroke, i) => { diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 7a1ab3b4f..74f18cbc8 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -97,9 +97,8 @@ export namespace Field { }); return script; } - export function toString(fieldIn: unknown) { - const field = fieldIn as FieldType; - if (typeof field === 'string' || typeof field === 'number' || typeof field === 'boolean') return String(field); + export function toString(field: FieldResult<FieldType> | FieldType | undefined) { + if (field instanceof Promise || typeof field === 'string' || typeof field === 'number' || typeof field === 'boolean') return String(field); return field?.[ToString]?.() || ''; } export function IsField(field: unknown): field is FieldType; @@ -111,7 +110,7 @@ export namespace Field { export function Copy(field: unknown) { return field instanceof ObjectField ? ObjectField.MakeCopy(field) : (field as FieldType); } - UndoManager.SetFieldPrinter(toString); + UndoManager.SetFieldPrinter((val: unknown) => (IsField(val) ? toString(val) : '')); } export type FieldType = number | string | boolean | ObjectField | RefField; export type Opt<T> = T | undefined; @@ -463,7 +462,6 @@ export class Doc extends RefField { }); } } - export namespace Doc { export let SelectOnLoad: Doc | undefined; export function SetSelectOnLoad(doc: Doc | undefined) { diff --git a/src/fields/RichTextUtils.ts b/src/fields/RichTextUtils.ts index b3534dde7..8c073c87b 100644 --- a/src/fields/RichTextUtils.ts +++ b/src/fields/RichTextUtils.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-namespace */ /* eslint-disable no-await-in-loop */ /* eslint-disable no-use-before-define */ import { AssertionError } from 'assert'; @@ -175,7 +174,6 @@ export namespace RichTextUtils { const indentMap = new Map<ListGroup, BulletPosition[]>(); let globalOffset = 0; const nodes: Node[] = []; - // eslint-disable-next-line no-restricted-syntax for (const element of structured) { if (Array.isArray(element)) { lists.push(element); @@ -374,11 +372,9 @@ export namespace RichTextUtils { const marksToStyle = async (nodes: (Node | null)[]): Promise<docsV1.Schema$Request[]> => { const requests: docsV1.Schema$Request[] = []; let position = 1; - // eslint-disable-next-line no-restricted-syntax for (const node of nodes) { if (node === null) { position += 2; - // eslint-disable-next-line no-continue continue; } const { marks, attrs, nodeSize } = node; @@ -390,9 +386,7 @@ export namespace RichTextUtils { }; let mark: Mark; const markMap = BuildMarkMap(marks); - // eslint-disable-next-line no-restricted-syntax for (const markName of Object.keys(schema.marks)) { - // eslint-disable-next-line no-cond-assign if (ignored.includes(markName) || !(mark = markMap[markName])) { continue; } diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 1e25a8a27..effe94219 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -61,27 +61,6 @@ export namespace WebSocket { Database.Instance.getDocuments(ids, callback); } - const pendingOps = new Map<string, { diff: Diff; socket: Socket }[]>(); - - function dispatchNextOp(id: string): unknown { - const next = pendingOps.get(id)?.shift(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const nextOp = (res: boolean) => dispatchNextOp(id); - if (next) { - const { diff, socket } = next; - // ideally, we'd call the Database update method for all actions, but for now we handle list insertion/removal on our own - switch (diff.diff.$addToSet ? 'add' : diff.diff.$remFromSet ? 'rem' : 'set') { - case 'add': return GetRefFieldLocal(id, (result) => addToListField(socket, diff, result, nextOp)); // prettier-ignore - case 'rem': return GetRefFieldLocal(id, (result) => remFromListField(socket, diff, result, nextOp)); // prettier-ignore - default: return Database.Instance.update(id, diff.diff, - () => nextOp(socket.broadcast.emit(MessageStore.UpdateField.Message, diff)), - false - ); // prettier-ignore - } - } - return !pendingOps.get(id)?.length && pendingOps.delete(id); - } - function addToListField(socket: Socket, diff: Diff, listDoc: serializedDoctype | undefined, cb: (res: boolean) => void): void { const $addToSet = diff.diff.$addToSet as serializedFieldsType; const updatefield = Array.from(Object.keys($addToSet ?? {}))[0]; @@ -181,6 +160,27 @@ export namespace WebSocket { } else cb(false); } + const pendingOps = new Map<string, { diff: Diff; socket: Socket }[]>(); + + function dispatchNextOp(id: string): unknown { + const next = pendingOps.get(id)?.shift(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const nextOp = (res: boolean) => dispatchNextOp(id); + if (next) { + const { diff, socket } = next; + // ideally, we'd call the Database update method for all actions, but for now we handle list insertion/removal on our own + switch (diff.diff.$addToSet ? 'add' : diff.diff.$remFromSet ? 'rem' : 'set') { + case 'add': return GetRefFieldLocal(id, (result) => addToListField(socket, diff, result, nextOp)); // prettier-ignore + case 'rem': return GetRefFieldLocal(id, (result) => remFromListField(socket, diff, result, nextOp)); // prettier-ignore + default: return Database.Instance.update(id, diff.diff, + () => nextOp(socket.broadcast.emit(MessageStore.UpdateField.Message, diff)), + false + ); // prettier-ignore + } + } + return !pendingOps.get(id)?.length && pendingOps.delete(id); + } + function UpdateField(socket: Socket, diff: Diff) { const curUser = socketMap.get(socket); if (curUser) { |