diff options
21 files changed, 1137 insertions, 116 deletions
diff --git a/package-lock.json b/package-lock.json index 4899f14e7..61eccb86e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3,70 +3,382 @@ "version": "1.0.0", "lockfileVersion": 1, "requires": true, - "dependencies": { - "@adobe/react-spectrum": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.35.0.tgz", - "integrity": "sha512-dhCKWKDS/xF02EpWluicH7Va6KAANbrUx4rb1UeeFT3czhwz44oCQ74ZYviVedNv6Bq7O22/kbMa20rDdyt/4A==", - "requires": { - "@internationalized/string": "^3.2.2", - "@react-aria/i18n": "^3.11.0", - "@react-aria/ssr": "^3.9.3", - "@react-aria/utils": "^3.24.0", - "@react-aria/visually-hidden": "^3.8.11", - "@react-spectrum/actionbar": "^3.4.4", - "@react-spectrum/actiongroup": "^3.10.4", - "@react-spectrum/avatar": "^3.0.11", - "@react-spectrum/badge": "^3.1.12", - "@react-spectrum/breadcrumbs": "^3.9.6", - "@react-spectrum/button": "^3.16.3", - "@react-spectrum/buttongroup": "^3.6.12", - "@react-spectrum/calendar": "^3.4.8", - "@react-spectrum/checkbox": "^3.9.5", - "@react-spectrum/combobox": "^3.12.4", - "@react-spectrum/contextualhelp": "^3.6.10", - "@react-spectrum/datepicker": "^3.9.5", - "@react-spectrum/dialog": "^3.8.10", - "@react-spectrum/divider": "^3.5.12", - "@react-spectrum/dnd": "^3.3.9", - "@react-spectrum/dropzone": "^3.0.0", - "@react-spectrum/filetrigger": "^3.0.0", - "@react-spectrum/form": "^3.7.5", - "@react-spectrum/icon": "^3.7.12", - "@react-spectrum/illustratedmessage": "^3.5.0", - "@react-spectrum/image": "^3.5.0", - "@react-spectrum/inlinealert": "^3.2.4", - "@react-spectrum/labeledvalue": "^3.1.13", - "@react-spectrum/layout": "^3.6.4", - "@react-spectrum/link": "^3.6.6", - "@react-spectrum/list": "^3.7.9", - "@react-spectrum/listbox": "^3.12.8", - "@react-spectrum/menu": "^3.19.0", - "@react-spectrum/meter": "^3.5.0", - "@react-spectrum/numberfield": "^3.9.2", - "@react-spectrum/overlays": "^5.6.0", - "@react-spectrum/picker": "^3.14.4", - "@react-spectrum/progress": "^3.7.6", - "@react-spectrum/provider": "^3.9.6", - "@react-spectrum/radio": "^3.7.5", - "@react-spectrum/searchfield": "^3.8.5", - "@react-spectrum/slider": "^3.6.8", - "@react-spectrum/statuslight": "^3.5.12", - "@react-spectrum/switch": "^3.5.4", - "@react-spectrum/table": "^3.12.9", - "@react-spectrum/tabs": "^3.8.9", - "@react-spectrum/tag": "^3.2.5", - "@react-spectrum/text": "^3.5.4", - "@react-spectrum/textfield": "^3.12.0", - "@react-spectrum/theme-dark": "^3.5.9", - "@react-spectrum/theme-default": "^3.5.9", - "@react-spectrum/theme-light": "^3.4.9", - "@react-spectrum/tooltip": "^3.6.6", - "@react-spectrum/view": "^3.6.9", - "@react-spectrum/well": "^3.4.12", - "@react-stately/collections": "^3.10.6", - "@react-stately/data": "^3.11.3", - "@react-types/shared": "^3.23.0", + "packages": { + "": { + "name": "dash", + "version": "1.0.0", + "dependencies": { + "@adobe/react-spectrum": "^3.32.2", + "@azure/storage-blob": "^12.17.0", + "@babel/preset-env": "^7.23.5", + "@babel/preset-react": "^7.23.3", + "@bundled-es-modules/pdfjs-dist": "^3.6.172-alpha.1", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@ffmpeg/core": "^0.12.5", + "@ffmpeg/ffmpeg": "^0.12.8", + "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-brands-svg-icons": "^6.5.1", + "@fortawesome/free-regular-svg-icons": "^6.5.1", + "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/react-fontawesome": "^0.2.0", + "@fullcalendar/core": "^6.1.10", + "@fullcalendar/daygrid": "^6.1.10", + "@fullcalendar/multimonth": "^6.1.10", + "@internationalized/date": "^3.5.0", + "@mui/icons-material": "^5.14.19", + "@mui/material": "^5.14.19", + "@octokit/core": "^6.0.1", + "@react-google-maps/api": "^2.19.2", + "@react-spring/web": "^9.7.3", + "@turf/turf": "^6.5.0", + "@types/bezier-js": "^4.1.3", + "@types/brotli": "^1.3.4", + "@types/cors": "^2.8.17", + "@types/d3-axis": "^3.0.6", + "@types/d3-color": "^3.1.3", + "@types/d3-scale": "^4.0.8", + "@types/d3-selection": "^3.0.10", + "@types/dom-speech-recognition": "0.0.4", + "@types/find-in-files": "^0.5.3", + "@types/fluent-ffmpeg": "^2.1.24", + "@types/formidable": "3.4.5", + "@types/geojson": "^7946.0.14", + "@types/google-maps": "^3.2.6", + "@types/mapbox-gl": "^3.1.0", + "@types/pdf-parse": "^1.1.4", + "@types/reveal": "^4.2.0", + "@types/supercluster": "^7.1.3", + "@types/web": "^0.0.147", + "@types/webpack-hot-middleware": "^2.25.9", + "@typescript-eslint/parser": "^7.8.0", + "@webscopeio/react-textarea-autocomplete": "^4.9.2", + "adm-zip": "^0.5.10", + "archiver": "^7.0.1", + "async": "^3.2.5", + "axios": "^1.6.2", + "babel": "^6.23.0", + "babel-loader": "^9.1.3", + "bcrypt-nodejs": "0.0.3", + "better-react-mathjax": "^2.0.3", + "bezier-curve": "^1.0.0", + "bezier-js": "^6.1.4", + "bingmaps-react": "^1.2.10", + "bluebird": "^3.7.2", + "body-parser": "^1.20.2", + "bootstrap": "^5.3.2", + "brotli": "^1.3.3", + "browndash-components": "^0.1.44", + "browser-assert": "^1.2.1", + "bson": "^6.2.0", + "canvas": "^2.11.2", + "chart.js": "^4.4.0", + "child_process": "^1.0.2", + "class-transformer": "^0.5.1", + "color": "^4.2.3", + "colors": "^1.4.0", + "compute-cosine-similarity": "^1.1.0", + "connect-flash": "^0.1.1", + "connect-mongo": "^5.1.0", + "cookie-parser": "^1.4.6", + "cookie-session": "^2.0.0", + "core-js": "^3.33.3", + "cors": "^2.8.5", + "css-loader": "^7.0.0", + "csstype": "^3.1.3", + "csv-parser": "^3.0.0", + "csv-stringify": "^6.4.4", + "csvtojson": "^2.0.10", + "D": "^1.0.0", + "d3": "^7.8.5", + "depcheck": "^1.4.7", + "dotenv": "^16.3.1", + "eslint-webpack-plugin": "^4.1.0", + "exif": "^0.6.0", + "exifr": "^7.1.3", + "express": "^4.18.2", + "express-flash": "0.0.2", + "express-session": "^1.17.3", + "express-validator": "^7.0.1", + "extract-colors": "^4.0.2", + "ffmpeg": "0.0.4", + "file-loader": "^6.2.0", + "file-saver": "^2.0.5", + "find-in-files": "^0.5.0", + "fit-curve": "^0.2.0", + "flexlayout-react": "^0.7.15", + "fluent-ffmpeg": "^2.1.2", + "forever-agent": "^0.6.1", + "fork-ts-checker-webpack-plugin": "^9.0.2", + "form-data": "^4.0.0", + "formidable": "3.5.1", + "function-plot": "^1.23.3", + "golden-layout": "^2.6.0", + "google-auth-library": "^9.4.1", + "googleapis": "^133.0.0", + "googlephotos": "^0.3.5", + "got": "^14.0.0", + "howler": "^2.2.4", + "html-to-image": "^1.11.11", + "html-to-text": "^9.0.5", + "html-webpack-plugin": "^5.5.3", + "http-browserify": "^1.7.0", + "https": "^1.0.0", + "https-browserify": "^1.0.0", + "i": "^0.3.7", + "image-data-uri": "^2.0.1", + "image-size": "^1.0.2", + "image-size-stream": "^1.1.0", + "is-plain-obj": "^4.1.0", + "jimp": "^0.22.10", + "jpeg-autorotate": "^9.0.0", + "jquery": "^3.7.1", + "js-datepicker": "^5.18.2", + "jsonschema": "^1.4.1", + "jszip": "^3.10.1", + "lodash": "^4.17.21", + "mapbox-gl": "^3.0.1", + "markdown-it": "^14.1.0", + "mathquill": "^0.10.1-a", + "md5-file": "^5.0.0", + "memorystream": "^0.3.1", + "mermaid": "^10.9.0", + "mobile-detect": "^1.4.5", + "mobx": "^6.12.0", + "mobx-react": "^9.1.0", + "mobx-utils": "^6.0.8", + "mongodb": "^6.3.0", + "mongoose": "^8.0.2", + "node-stream-zip": "^1.15.0", + "nodemailer": "^6.9.7", + "nodemon": "^3.0.2", + "npm": "^10.2.5", + "openai": "^4.26.0", + "p-limit": "^5.0.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-local": "^1.0.0", + "path-browserify": "^1.0.1", + "pdf-parse": "^1.1.1", + "pdfjs": "^2.4.7", + "pdfjs-dist": "^4.0.269", + "probe-image-size": "^7.2.3", + "process": "^0.11.10", + "prosemirror-commands": "^1.5.2", + "prosemirror-find-replace": "^0.9.0", + "prosemirror-history": "^1.3.2", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.19.3", + "prosemirror-schema-list": "^1.3.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.8.0", + "prosemirror-view": "^1.32.5", + "pug": "^3.0.2", + "query-string": "^7.1.3", + "querystring-es3": "^0.2.1", + "raw-loader": "^4.0.2", + "rc-switch": "^4.1.0", + "react": "^18.2.0", + "react-autosuggest": "^10.1.0", + "react-awesome-reveal": "^4.2.7", + "react-color": "^2.19.3", + "react-compound-slider": "^3.4.0", + "react-datepicker": "^6.1.0", + "react-dom": "^18.2.0", + "react-grid-layout": "^1.4.4", + "react-icons": "^5.0.1", + "react-jsx-parser": "^1.29.0", + "react-latex-next": "^3.0.0", + "react-loading": "^2.0.3", + "react-map-gl": "^7.1.6", + "react-markdown": "^9.0.1", + "react-measure": "^2.5.2", + "react-resizable": "^3.0.5", + "react-select": "^5.8.0", + "react-textarea-autosize": "^8.5.3", + "react-type-animation": "^3.2.0", + "react-xarrows": "^2.0.2", + "readline": "^1.3.0", + "recharts": "^2.10.3", + "rehype-katex": "^7.0.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "request": "^2.88.2", + "request-promise": "^4.2.6", + "reveal.js": "^5.0.2", + "rimraf": "^5.0.5", + "sass": "^1.69.5", + "sass-loader": "^14.2.0", + "serializr": "^3.0.2", + "shelljs": "^0.8.5", + "socket.io": "^4.7.2", + "socket.io-client": "^4.7.2", + "standard-http-error": "^2.0.1", + "stream-browserify": "^3.0.0", + "styled-components": "^6.1.1", + "supercluster": "^8.0.1", + "textarea-caret": "^3.1.0", + "tough-cookie": "^4.1.3", + "tslint": "^6.1.3", + "tslint-loader": "^3.5.4", + "typescript": "^5.3.3", + "typescript-collections": "^1.3.3", + "typescript-language-server": "^4.1.3", + "uninstall": "^0.0.0", + "url": "^0.11.3", + "url-loader": "^4.1.1", + "util": "^0.12.5", + "uuid": "^9.0.1", + "valid-url": "^1.0.9", + "web-request": "^1.0.7", + "webpack": "^5.89.0", + "webpack-cli": "^5.1.4", + "webpack-dev-middleware": "^7.0.0", + "webpack-hot-middleware": "^2.25.4", + "wikijs": "^6.4.1", + "words-to-numbers": "^1.5.1", + "xoauth2": "^1.2.0", + "xregexp": "^5.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.1.1", + "@types/adm-zip": "^0.5.5", + "@types/animejs": "^3.1.12", + "@types/archiver": "^6.0.2", + "@types/async": "^3.2.24", + "@types/bcrypt-nodejs": "0.0.31", + "@types/bluebird": "^3.5.42", + "@types/body-parser": "^1.19.5", + "@types/chai": "^4.3.11", + "@types/color": "^3.0.6", + "@types/cookie-parser": "^1.4.6", + "@types/cookie-session": "^2.0.48", + "@types/d3": "^7.4.3", + "@types/exif": "^0.6.5", + "@types/express": "^4.17.21", + "@types/express-session": "^1.17.10", + "@types/file-saver": "^2.0.7", + "@types/howler": "^2.2.11", + "@types/html-to-text": "^9.0.4", + "@types/jquery": "^3.5.29", + "@types/libxmljs": "^0.18.12", + "@types/lodash": "^4.14.202", + "@types/mocha": "^10.0.6", + "@types/node": "^20.10.3", + "@types/nodemailer": "^6.4.14", + "@types/passport": "^1.0.16", + "@types/passport-google-oauth20": "^2.0.14", + "@types/passport-local": "^1.0.38", + "@types/rc-switch": "^1.9.5", + "@types/react": "^18.2.41", + "@types/react-autosuggest": "^10.1.10", + "@types/react-color": "^3.0.10", + "@types/react-datepicker": "^6.2.0", + "@types/react-dom": "^18.2.17", + "@types/react-grid-layout": "^1.3.5", + "@types/react-measure": "^2.0.12", + "@types/react-reconciler": "^0.28.8", + "@types/request": "^2.48.12", + "@types/request-promise": "^4.1.51", + "@types/shelljs": "^0.8.15", + "@types/uuid": "^9.0.7", + "@types/valid-url": "^1.0.7", + "@types/webpack": "^5.28.5", + "@types/youtube": "0.0.50", + "chai": "^5.0.0", + "cross-env": "^7.0.3", + "eslint": "^8.57.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-node": "^4.1.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^4.6.0", + "globals": "^15.1.0", + "jsdom": "^24.0.0", + "mocha": "^10.2.0", + "prettier": "^3.1.0", + "scss-loader": "0.0.1", + "style-loader": "^4.0.0", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.1", + "ts-node-dev": "^2.0.0", + "typescript-eslint": "^7.8.0", + "webpack-dev-server": "^5.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@adobe/react-spectrum": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.35.1.tgz", + "integrity": "sha512-QNhsaEHv5S5Vqsk7b8aCV9F7qAnWw8VJ/Nep/SOjeiJ7vK993jEOetEhSsUIQ8VHsMKs6qkTtZr0/DKoV+Z/9w==", + "dependencies": { + "@internationalized/string": "^3.2.3", + "@react-aria/i18n": "^3.11.1", + "@react-aria/ssr": "^3.9.4", + "@react-aria/utils": "^3.24.1", + "@react-aria/visually-hidden": "^3.8.12", + "@react-spectrum/actionbar": "^3.4.5", + "@react-spectrum/actiongroup": "^3.10.5", + "@react-spectrum/avatar": "^3.0.12", + "@react-spectrum/badge": "^3.1.13", + "@react-spectrum/breadcrumbs": "^3.9.7", + "@react-spectrum/button": "^3.16.4", + "@react-spectrum/buttongroup": "^3.6.13", + "@react-spectrum/calendar": "^3.4.9", + "@react-spectrum/checkbox": "^3.9.6", + "@react-spectrum/combobox": "^3.12.5", + "@react-spectrum/contextualhelp": "^3.6.11", + "@react-spectrum/datepicker": "^3.9.6", + "@react-spectrum/dialog": "^3.8.11", + "@react-spectrum/divider": "^3.5.13", + "@react-spectrum/dnd": "^3.3.10", + "@react-spectrum/dropzone": "^3.0.1", + "@react-spectrum/filetrigger": "^3.0.1", + "@react-spectrum/form": "^3.7.6", + "@react-spectrum/icon": "^3.7.13", + "@react-spectrum/illustratedmessage": "^3.5.1", + "@react-spectrum/image": "^3.5.1", + "@react-spectrum/inlinealert": "^3.2.5", + "@react-spectrum/labeledvalue": "^3.1.14", + "@react-spectrum/layout": "^3.6.5", + "@react-spectrum/link": "^3.6.7", + "@react-spectrum/list": "^3.7.10", + "@react-spectrum/listbox": "^3.12.9", + "@react-spectrum/menu": "^3.19.1", + "@react-spectrum/meter": "^3.5.1", + "@react-spectrum/numberfield": "^3.9.3", + "@react-spectrum/overlays": "^5.6.1", + "@react-spectrum/picker": "^3.14.5", + "@react-spectrum/progress": "^3.7.7", + "@react-spectrum/provider": "^3.9.7", + "@react-spectrum/radio": "^3.7.6", + "@react-spectrum/searchfield": "^3.8.6", + "@react-spectrum/slider": "^3.6.9", + "@react-spectrum/statuslight": "^3.5.13", + "@react-spectrum/switch": "^3.5.5", + "@react-spectrum/table": "^3.12.10", + "@react-spectrum/tabs": "^3.8.10", + "@react-spectrum/tag": "^3.2.6", + "@react-spectrum/text": "^3.5.5", + "@react-spectrum/textfield": "^3.12.1", + "@react-spectrum/theme-dark": "^3.5.10", + "@react-spectrum/theme-default": "^3.5.10", + "@react-spectrum/theme-light": "^3.4.10", + "@react-spectrum/tooltip": "^3.6.7", + "@react-spectrum/view": "^3.6.10", + "@react-spectrum/well": "^3.4.13", + "@react-stately/collections": "^3.10.7", + "@react-stately/data": "^3.11.4", + "@react-types/shared": "^3.23.1", "client-only": "^0.0.1" } }, @@ -17427,7 +17739,7 @@ } } }, - "leac": { + "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==" diff --git a/package.json b/package.json index e83e6b01b..7d58bd0c0 100644 --- a/package.json +++ b/package.json @@ -222,13 +222,14 @@ "js-datepicker": "^5.18.2", "jsonschema": "^1.4.1", "jszip": "^3.10.1", + "ldrs": "^1.0.2", "lodash": "^4.17.21", "mapbox-gl": "^3.0.1", "markdown-it": "^14.1.0", "mathquill": "^0.10.1-a", "md5-file": "^5.0.0", "memorystream": "^0.3.1", - "mermaid": "^10.9.0", + "mermaid": "^10.9.1", "mobile-detect": "^1.4.5", "mobx": "^6.12.0", "mobx-react": "^9.1.0", diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index 8f95068db..a9ea889b3 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -16,6 +16,7 @@ export enum DocumentType { SCREENSHOT = 'screenshot', FONTICON = 'fonticonbox', SEARCH = 'search', // search query + IMAGEGROUPER = 'imagegrouper', LABEL = 'label', // simple text label BUTTON = 'button', // onClick button WEBCAM = 'webcam', // webcam diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 324d80581..c75c10eb8 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -790,6 +790,10 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.SEARCH), new List<Doc>([]), options); } + export function ImageGrouperDocument(options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.IMAGEGROUPER), undefined, options); + } + export function LoadingDocument(file: File | string, options: DocumentOptions) { return InstanceFromProto(Prototypes.get(DocumentType.LOADING), undefined, { _height: 150, _width: 200, title: typeof file === 'string' ? file : file.name, ...options }, undefined, ''); } diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index f3b5e5b1d..f42c15c14 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -458,6 +458,7 @@ pie title Minerals in my tap water { title: "Shared", toolTip: "Shared Docs", target: Doc.MySharedDocs, ignoreClick: true, icon: "users", funcs: {badgeValue: badgeValue}}, { title: "Trails", toolTip: "Trails ⌘R", target: Doc.UserDoc(), ignoreClick: true, icon: "pres-trail", funcs: {target: getActiveDashTrails}}, { title: "User Doc", toolTip: "User Doc", target: this.setupUserDocView(doc, "myUserDocView"), ignoreClick: true, icon: "address-card",funcs: {hidden: "IsNoviceMode()"} }, + { title: "Image Grouper", toolTip: "Image Grouper", target: this.setupImageGrouper(doc, "myImageGrouper"), ignoreClick: true, icon: "folder-open", hidden: false } ].map(tuple => ({...tuple, scripts:{onClick: 'selectMainMenu(this)'}})); } @@ -493,6 +494,12 @@ pie title Minerals in my tap water _lockedPosition: true, _type_collection: CollectionViewType.Schema }); } + static setupImageGrouper(doc: Doc, field: string) { + return DocUtils.AssignDocField(doc, field, (opts) => Docs.Create.ImageGrouperDocument(opts), { + dontRegisterView: true, backgroundColor: "dimgray", ignoreClick: true, title: "Image Grouper", isSystem: true, childDragAction: dropActionType.embed, + _lockedPosition: true, _type_collection: CollectionViewType.Schema }); + } + /// Initializes the panel of draggable tools that is opened from the left sidebar. static setupToolsBtnPanel(doc: Doc, field:string) { const allTools = DocListCast(DocCast(doc[field])?.data); diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index 487868169..a75c7098c 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -31,6 +31,7 @@ import { DocumentLinksButton } from './nodes/DocumentLinksButton'; import { DocumentView } from './nodes/DocumentView'; import { OpenWhere } from './nodes/OpenWhere'; import { DashFieldView } from './nodes/formattedText/DashFieldView'; +import { DocData } from '../../fields/DocSymbols'; @observer export class DocumentButtonBar extends ObservableReactComponent<{ views: () => (DocumentView | undefined)[]; stack?: any }> { @@ -282,6 +283,23 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( ); } + @computed + get keywordButton() { + const targetDoc = this.view0?.Document; + return !targetDoc ? null : ( + <Tooltip title={<div className="dash-keyword-button">Open keyword menu</div>}> + <div + className="documentButtonBar-icon" + style={{ color: 'white' }} + onClick={() => { + targetDoc[DocData].showLabels = !targetDoc[DocData].showLabels; + }}> + <FontAwesomeIcon className="documentdecorations-icon" icon="tag" /> + </div> + </Tooltip> + ); + } + @observable _isRecording = false; _stopFunc: () => void = emptyFunction; @computed @@ -452,6 +470,7 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( <div className="documentButtonBar-button">{this.pinButton}</div> <div className="documentButtonBar-button">{this.recordButton}</div> <div className="documentButtonBar-button">{this.calendarButton}</div> + <div className="documentButtonBar-button">{this.keywordButton}</div> {!Doc.UserDoc().documentLinksButton_fullMenu ? null : <div className="documentButtonBar-button">{this.shareButton}</div>} <div className="documentButtonBar-button">{this.menuButton}</div> </div> diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 93c3e3338..20bf8fd9f 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -88,6 +88,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora (this._showNothing = !inputting && !DocumentButtonBar.Instance?._tooltipOpen && !(this.Bounds.x !== Number.MAX_VALUE && // (this.Bounds.x > center.x+x || this.Bounds.r < center.x+x || this.Bounds.y > center.y+y || this.Bounds.b < center.y+y ))); + })); // prettier-ignore } diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx new file mode 100644 index 000000000..d94f011f4 --- /dev/null +++ b/src/client/views/KeywordBox.tsx @@ -0,0 +1,177 @@ +import { Colors, IconButton } from 'browndash-components'; +import { action, computed, makeObservable, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; +import React from 'react'; +import { Doc } from '../../fields/Doc'; +import { DocData } from '../../fields/DocSymbols'; +import { List } from '../../fields/List'; +import { DragManager, SetupDrag } from '../util/DragManager'; +import { SnappingManager } from '../util/SnappingManager'; +import { DocumentView } from './nodes/DocumentView'; +import { ObservableReactComponent } from './ObservableReactComponent'; + +interface KeywordItemProps { + doc: Doc; + label: string; + setToEditing: () => void; + isEditing: boolean; +} + +@observer +export class KeywordItem extends ObservableReactComponent<KeywordItemProps> { + constructor(props: any) { + super(props); + makeObservable(this); + this.ref = React.createRef(); + } + + private _dropDisposer?: DragManager.DragDropDisposer; + private ref: React.RefObject<HTMLDivElement>; + + protected createDropTarget = (ele: HTMLDivElement) => { + this._dropDisposer?.(); + SetupDrag(this.ref, () => undefined); + //ele && (this._dropDisposer = DragManager. (ele, this.onInternalDrop.bind(this), this.layoutDoc)); + //ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc)); + }; + + @action + removeLabel = () => { + if (this._props.doc[DocData].data_labels) { + this._props.doc[DocData].data_labels = (this._props.doc[DocData].data_labels as List<string>).filter(label => label !== this._props.label) as List<string>; + this._props.doc![DocData][`${this._props.label}`] = false; + } + }; + + render() { + return ( + <div className="keyword" onClick={this._props.setToEditing} onPointerDown={() => {}} ref={this.ref}> + {this._props.label} + {this.props.isEditing && <IconButton tooltip={'Remove label'} onPointerDown={this.removeLabel} icon={'X'} style={{ width: '8px', height: '8px', marginLeft: '10px' }} />} + </div> + ); + } +} + +interface KeywordBoxProps { + doc: Doc; + isEditing: boolean; +} + +@observer +export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> { + @observable _currentInput: string = ''; + //private disposer: () => void; + + constructor(props: any) { + super(props); + makeObservable(this); + } + + // componentDidMount(): void { + // reaction( + // () => ({ + // isDragging: SnappingManager.IsDragging, + // selectedDoc: DocumentView.SelectedDocs().lastElement(), + // isEditing: this._props.isEditing, + // }), + // ({ isDragging, selectedDoc, isEditing }) => { + // if (isDragging || selectedDoc !== this._props.doc || !isEditing) { + // this.setToView(); + // } + // } + // ); + // } + + // componentWillUnmount() { + // this.disposer(); + // } + + @action + setToEditing = () => { + this._props.isEditing = true; + }; + + @action + setToView = () => { + this._props.isEditing = false; + }; + + submitLabel = () => { + if (this._currentInput.trim()) { + if (!this._props.doc[DocData].data_labels) { + this._props.doc[DocData].data_labels = new List<string>(); + } + + (this._props.doc![DocData].data_labels! as List<string>).push(this._currentInput.trim()); + this._props.doc![DocData][`${this._currentInput}`] = true; + this._currentInput = ''; // Clear the input box + } + }; + + @action + onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + this._currentInput = e.target.value; + }; + + render() { + const keywordsList = this._props.doc[DocData].data_labels ? this._props.doc[DocData].data_labels : new List<string>(); + const seldoc = DocumentView.SelectedDocs().lastElement(); + if (SnappingManager.IsDragging || !(seldoc === this._props.doc) || !this._props.isEditing) { + setTimeout( + action(() => { + if ((keywordsList as List<string>).length === 0) { + this._props.doc[DocData].showLabels = false; + } + this.setToView(); + }) + ); + } + + return ( + <div className="keywords-container" style={{ backgroundColor: this._props.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, borderColor: this._props.isEditing ? Colors.BLACK : Colors.TRANSPARENT }}> + <div className="keywords-list"> + {(keywordsList as List<string>).map(label => { + return <KeywordItem doc={this._props.doc} label={label} setToEditing={this.setToEditing} isEditing={this._props.isEditing}></KeywordItem>; + })} + </div> + {this._props.isEditing ? ( + <div className="keyword-editing-box"> + <div className="keyword-input-box"> + <input + value={this._currentInput} + autoComplete="off" + onChange={this.onInputChange} + onKeyDown={e => { + e.key === 'Enter' ? this.submitLabel() : null; + e.stopPropagation(); + }} + type="text" + placeholder="Input keywords for document..." + aria-label="keyword-input" + className="keyword-input" + style={{ width: '100%', borderRadius: '5px' }} + /> + </div> + <div className="keyword-buttons"> + <IconButton + tooltip={'Close Menu'} + onPointerDown={() => { + if ((keywordsList as List<string>).length === 0) { + this._props.doc[DocData].showLabels = false; + } else { + this.setToView(); + } + }} + icon={'x'} + style={{ width: '4px' }} + /> + </div> + </div> + ) : ( + <div></div> + )} + </div> + ); + } +} diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index 43b9a6b39..8242e7c27 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -60,6 +60,7 @@ import { SummaryView } from './nodes/formattedText/SummaryView'; import { ImportElementBox } from './nodes/importBox/ImportElementBox'; import { PresBox, PresElementBox } from './nodes/trails'; import { SearchBox } from './search/SearchBox'; +import { ImageLabelBox } from './collections/collectionFreeForm/ImageLabelBox'; dotenv.config(); @@ -131,6 +132,7 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' }; PresBox, PresElementBox, SearchBox, + ImageLabelBox, //Here! FunctionPlotBox, InkingStroke, LinkBox, diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index b06d785e1..7ad5cd88c 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -76,6 +76,7 @@ import { PresBox } from './nodes/trails'; import { AnchorMenu } from './pdf/AnchorMenu'; import { GPTPopup } from './pdf/GPTPopup/GPTPopup'; import { TopBar } from './topbar/TopBar'; +import { ImageLabelBox, ImageLabelBoxData } from './collections/collectionFreeForm/ImageLabelBox'; const { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } = require('./global/globalCssVariables.module.scss'); // prettier-ignore const _global = (window /* browser */ || global) /* node */ as any; diff --git a/src/client/views/StyleProvider.scss b/src/client/views/StyleProvider.scss index ce00f6101..7cc06f922 100644 --- a/src/client/views/StyleProvider.scss +++ b/src/client/views/StyleProvider.scss @@ -53,3 +53,45 @@ .styleProvider-treeView-icon { opacity: 0; } + +.keywords-container { + display: flex; + flex-wrap: wrap; + flex-direction: column; + padding-bottom: 4px; + border: 1px solid; + border-radius: 4px; +} + +.keywords-list { + display: flex; + flex-wrap: wrap; +} + +.keyword { + padding: 5px 5px; + background-color: lightblue; + border: 1px solid black; + border-radius: 5px; + white-space: nowrap; + display: flex; + align-items: center; +} + +.keyword-editing-box { + margin-top: 8px; +} + +.keyword-input-box { + // display: flex; + // align-items: center; + // align-content: center; + margin: auto; + align-self: center; + width: 90%; +} + +.keyword-buttons { + margin-left: auto; + width: 10%; +} diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index b7f8a3170..f4d73cd1d 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -12,7 +12,9 @@ import { BsArrowDown, BsArrowDownUp, BsArrowUp } from 'react-icons/bs'; import { FaFilter } from 'react-icons/fa'; import { ClientUtils, DashColor, lightOrDark } from '../../ClientUtils'; import { Doc, Opt, StrListCast } from '../../fields/Doc'; +import { DocData } from '../../fields/DocSymbols'; import { Id } from '../../fields/FieldSymbols'; +import { List } from '../../fields/List'; import { ScriptField } from '../../fields/ScriptField'; import { BoolCast, Cast, DocCast, ImageCast, NumCast, ScriptCast, StrCast } from '../../fields/Types'; import { AudioAnnoState } from '../../server/SharedMediaTypes'; @@ -23,6 +25,7 @@ import { SnappingManager } from '../util/SnappingManager'; import { undoBatch, UndoManager } from '../util/UndoManager'; import { TreeSort } from './collections/TreeSort'; import { Colors } from './global/globalEnums'; +import { KeywordBox } from './KeywordBox'; import { DocumentView, DocumentViewProps } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; import { StyleProp } from './StyleProp'; @@ -367,12 +370,20 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & </Tooltip> ); }; + const keywords = () => { + if (doc && doc![DocData].showLabels && (!doc[DocData].data_labels || (doc[DocData].data_labels as List<string>).length === 0)){ + return (<KeywordBox isEditing={true} doc={doc}></KeywordBox>) + } else if (doc && doc![DocData].data_labels && doc![DocData].showLabels) { + return (<KeywordBox isEditing={false} doc={doc}></KeywordBox>) + } + } return ( <> {paint()} {lock()} {filter()} {audio()} + {keywords()} </> ); } diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss new file mode 100644 index 000000000..819c72760 --- /dev/null +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss @@ -0,0 +1,85 @@ +.image-box-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 10px; + line-height: 1; + background: none; + z-index: 1000; + padding: 0px; + overflow: auto; + cursor: default; +} + +.image-label-list { + display: flex; + flex-direction: column; + align-items: center; // Centers the content vertically in the flex container + width: 100%; + + > div { + display: flex; + justify-content: space-between; // Puts the content and delete button on opposite ends + align-items: center; + width: 100%; + margin-top: 8px; // Adds space between label rows + background-color: black; + + p { + text-align: center; // Centers the text of the paragraph + font-size: large; + vertical-align: middle; + margin-left: 10px; + } + + .IconButton { + // Styling for the delete button + margin-left: auto; // Pushes the button to the far right + } + } +} + +.image-information-list { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: 10px; +} + +.image-information { + border: 1px solid; + width: 100%; + display: inline-flex; + flex-direction: column; + justify-content: center; + align-items: center; + overflow: hidden; + padding: 2px; + overflow-x: auto; + overflow-y: auto; + + img { + max-width: 200px; + max-height: 200px; + width: auto; + height: auto; + } +} + +.image-information-labels { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .image-label { + margin-top: 5px; + margin-bottom: 5px; + padding: 3px; + border-radius: 2px; + border: solid 1px; + } +} diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx new file mode 100644 index 000000000..fec4d3e12 --- /dev/null +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx @@ -0,0 +1,342 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Colors, IconButton } from 'browndash-components'; +import { action, computed, makeObservable, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; +import React from 'react'; +import { Doc, NumListCast, Opt } from '../../../../fields/Doc'; +import { Docs } from '../../../documents/Documents'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { ViewBoxBaseComponent } from '../../DocComponent'; +import { FieldView, FieldViewProps } from '../../nodes/FieldView'; +import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; +import './ImageLabelBox.scss'; +import { MainView } from '../../MainView'; +import 'ldrs/ring'; +import { ring } from 'ldrs'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { ImageCast } from '../../../../fields/Types'; +import { DocData } from '../../../../fields/DocSymbols'; +import { SettingsManager } from '../../../util/SettingsManager'; +import { CollectionCardView } from '../CollectionCardDeckView'; +import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT'; +import { numberRange, Utils } from '../../../../Utils'; +import { List } from '../../../../fields/List'; +import { DragManager } from '../../../util/DragManager'; +import { OpenWhere } from '../../nodes/OpenWhere'; +import similarity from 'compute-cosine-similarity'; +import { DocumentView } from '../../nodes/DocumentView'; + +export class ImageInformationItem {} + +export class ImageLabelBoxData { + static _instance: ImageLabelBoxData; + @observable _docs: Doc[] = []; + @observable _labelGroups: string[] = []; + + constructor() { + makeObservable(this); + ImageLabelBoxData._instance = this; + } + public static get Instance() { + return ImageLabelBoxData._instance ?? new ImageLabelBoxData(); + } + + @action + public setData = (docs: Doc[]) => { + this._docs = docs; + }; + + @action + addLabel = (label: string) => { + label = label.toUpperCase().trim(); + if (label.length > 0) { + if (!this._labelGroups.includes(label)) { + this._labelGroups = [...this._labelGroups, label]; + } + } + }; + + @action + removeLabel = (label: string) => { + const labelUp = label.toUpperCase(); + this._labelGroups = this._labelGroups.filter(group => group !== labelUp); + }; +} + +@observer +export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { + public static LayoutString(fieldKey: string) { + return FieldView.LayoutString(ImageLabelBox, fieldKey); + } + + private _dropDisposer?: DragManager.DragDropDisposer; + public static Instance: ImageLabelBox; + private _inputRef = React.createRef<HTMLInputElement>(); + @observable _loading: boolean = false; + private _currentLabel: string = ''; + + protected createDropTarget = (ele: HTMLDivElement) => { + this._dropDisposer?.(); + ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc)); + }; + + protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean { + const { docDragData } = de.complete; + if (docDragData) { + ImageLabelBoxData.Instance.setData(ImageLabelBoxData.Instance._docs.concat(docDragData.droppedDocuments)); + return false; + } + return false; + } + + @computed get _labelGroups() { + return ImageLabelBoxData.Instance._labelGroups; + } + + @computed get _selectedImages() { + // return DocListCast(this.dataDoc.data); + return ImageLabelBoxData.Instance._docs; + } + @observable _displayImageInformation: boolean = false; + + constructor(props: any) { + super(props); + makeObservable(this); + ring.register(); + ImageLabelBox.Instance = this; + } + + // ImageLabelBox.Instance.setData() + /** + * This method is called when the SearchBox component is first mounted. When the user opens + * the search panel, the search input box is automatically selected. This allows the user to + * type in the search input box immediately, without needing clicking on it first. + */ + componentDidMount() { + this.classifyImagesInBox(); + reaction( + () => this._selectedImages, + () => this.classifyImagesInBox() + ); + } + + @action + groupImages = () => { + this.groupImagesInBox(); + }; + + @action + startLoading = () => { + this._loading = true; + }; + + @action + endLoading = () => { + this._loading = false; + }; + + @action + toggleDisplayInformation = () => { + this._displayImageInformation = !this._displayImageInformation; + if (this._displayImageInformation) { + this._selectedImages.forEach(doc => (doc[DocData].showLabels = true)); + } else { + this._selectedImages.forEach(doc => (doc[DocData].showLabels = false)); + } + }; + + @action + submitLabel = () => { + const input = document.getElementById('new-label') as HTMLInputElement; + ImageLabelBoxData.Instance.addLabel(this._currentLabel); + this._currentLabel = ''; + input.value = ''; + }; + + onInputChange = action((e: React.ChangeEvent<HTMLInputElement>) => { + this._currentLabel = e.target.value; + }); + + classifyImagesInBox = async () => { + this.startLoading(); + + // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them. + + const imageInfos = this._selectedImages.map(async doc => { + if (!doc[DocData].data_labels) { + const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.'); + return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 => + !hrefBase64 ? undefined : + gptImageLabel(hrefBase64).then(labels => + ({ doc, labels }))) ; // prettier-ignore + } + }); + + (await Promise.all(imageInfos)).forEach(imageInfo => { + if (imageInfo) { + imageInfo.doc[DocData].data_labels = new List<string>(); + + const labels = imageInfo.labels.split('\n'); + labels.forEach(label => { + label = label.replace(/^\d+\.\s*|-|\*/, '').trim(); + imageInfo.doc[DocData][`${label}`] = true; + (imageInfo.doc[DocData].data_labels as List<string>).push(label); + }); + } + }); + + this.endLoading(); + }; + + /** + * Groups images to most similar labels. + */ + groupImagesInBox = action(async () => { + this.startLoading(); + + for (const doc of this._selectedImages) { + for (let index = 0; index < (doc[DocData].data_labels as List<string>).length; index++) { + const label = (doc[DocData].data_labels as List<string>)[index]; + const embedding = await gptGetEmbedding(label); + doc[`data_labels_embedding_${index + 1}`] = new List<number>(embedding); + } + } + + const labelToEmbedding = new Map<string, number[]>(); + // Create embeddings for the labels. + await Promise.all(this._labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding)))); + + // For each image, loop through the labels, and calculate similarity. Associate it with the + // most similar one. + this._selectedImages.forEach(doc => { + const embedLists = numberRange((doc[DocData].data_labels as List<string>).length).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`]))); + const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (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, + { label: '', similarityScore: 0, }); // prettier-ignore + doc[DocData].data_label = mostSimilarLabelCollect; // The label most similar to the image's contents. + }); + + this.endLoading(); + + if (this._selectedImages) { + MarqueeOptionsMenu.Instance.groupImages(); + } + + MainView.Instance.closeFlyout(); + }); + + render() { + if (this._loading) { + return ( + <div className="image-box-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}> + <l-ring size="60" color="white" /> + </div> + ); + } + + 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> + </div> + ); + } + + return ( + <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele)}> + <div className="searchBox-bar" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}> + <IconButton + tooltip={'See image information'} + onPointerDown={this.toggleDisplayInformation} + icon={this._displayImageInformation ? <FontAwesomeIcon icon="caret-up" /> : <FontAwesomeIcon icon="caret-down" />} + color={MarqueeOptionsMenu.Instance.userColor} + style={{ width: '19px' }} + /> + <input + defaultValue="" + autoComplete="off" + onChange={this.onInputChange} + onKeyDown={e => { + e.key === 'Enter' ? this.submitLabel() : null; + e.stopPropagation(); + }} + type="text" + placeholder="Input groups for images to be put into..." + aria-label="label-input" + id="new-label" + className="searchBox-input" + style={{ width: '100%', borderRadius: '5px' }} + ref={this._inputRef} + /> + <IconButton + tooltip={'Add a label'} + onPointerDown={() => { + const input = document.getElementById('new-label') as HTMLInputElement; + ImageLabelBoxData.Instance.addLabel(this._currentLabel); + this._currentLabel = ''; + input.value = ''; + }} + icon={<FontAwesomeIcon icon="plus" />} + color={MarqueeOptionsMenu.Instance.userColor} + style={{ width: '19px' }} + /> + {this._labelGroups.length > 0 ? <IconButton tooltip={'Group Images'} onPointerDown={this.groupImages} icon={<FontAwesomeIcon icon="object-group" />} color={Colors.MEDIUM_BLUE} style={{ width: '19px' }} /> : <div></div>} + </div> + <div> + <div className="image-label-list"> + {this._labelGroups.map(group => { + return ( + <div key={Utils.GenerateGuid()}> + <p style={{ color: MarqueeOptionsMenu.Instance.userColor }}>{group}</p> + <IconButton + tooltip={'Remove Label'} + onPointerDown={() => { + ImageLabelBoxData.Instance.removeLabel(group); + }} + icon={'x'} + color={MarqueeOptionsMenu.Instance.userColor} + style={{ width: '8px' }} + /> + </div> + ); + })} + </div> + </div> + {this._displayImageInformation ? ( + <div className="image-information-list"> + {this._selectedImages.map(doc => { + const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.'); + return ( + <div className="image-information" style={{ borderColor: SettingsManager.userColor }} key={Utils.GenerateGuid()}> + <img + src={`${name}_o.${type}`} + onClick={async () => { + await DocumentView.showDocument(doc, { willZoomCentered: true }); + }}></img> + <div className="image-information-labels" onClick={() => this._props.addDocTab(doc, OpenWhere.addRightKeyvalue)}> + {(doc[DocData].data_labels as List<string>).map(label => { + return ( + <div key={Utils.GenerateGuid()} className="image-label" style={{ backgroundColor: SettingsManager.userVariantColor, borderColor: SettingsManager.userColor }}> + {label} + </div> + ); + })} + </div> + </div> + ); + })} + </div> + ) : ( + <div></div> + )} + </div> + ); + } +} + +Docs.Prototypes.TemplateMap.set(DocumentType.IMAGEGROUPER, { + layout: { view: ImageLabelBox, dataField: 'data' }, + options: { acl: '', _width: 400 }, +}); diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx index 7f27c6b5c..73befb205 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx +++ b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx @@ -77,7 +77,7 @@ export class ImageLabelHandler extends ObservableReactComponent<{}> { }}> <div> <IconButton tooltip={'Cancel'} onPointerDown={this.hideLabelhandler} icon={<FontAwesomeIcon icon="eye-slash" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} /> - <input aria-label="label-input" id="new-label" type="text" style={{ color: 'black' }} /> + <input aria-label="label-input" id="new-label" type="text" placeholder="Input a classification" style={{ color: 'black' }} /> <IconButton tooltip={'Add Label'} onPointerDown={() => { diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx index f02cd9d45..b94a22d04 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx @@ -39,7 +39,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { <IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} /> <IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} /> <IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} /> - <IconButton tooltip="Classify Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> + <IconButton tooltip="Classify and Sort Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> </> ); return this.getElement(buttons); diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index dc15c83c5..07e3acb1d 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react'; import * as React from 'react'; import { ClientUtils, lightOrDark, returnFalse } from '../../../../ClientUtils'; import { intersectRect, numberRange } from '../../../../Utils'; -import { Doc, NumListCast, Opt } from '../../../../fields/Doc'; +import { Doc, DocListCast, NumListCast, Opt } from '../../../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { InkData, InkField, InkTool } from '../../../../fields/InkField'; @@ -36,6 +36,9 @@ import { CollectionFreeFormView } from './CollectionFreeFormView'; import { ImageLabelHandler } from './ImageLabelHandler'; import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; import './MarqueeView.scss'; +import { MainView } from '../../MainView'; +import { ImageLabelBox, ImageLabelBoxData } from './ImageLabelBox'; +import { SearchBox } from '../../search/SearchBox'; interface MarqueeViewProps { getContainerTransform: () => Transform; @@ -53,6 +56,9 @@ interface MarqueeViewProps { slowLoadDocuments: (files: File[] | string, options: DocumentOptions, generatedDocuments: Doc[], text: string, completed: ((doc: Doc[]) => void) | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => Promise<void>; } +/** + * A component that deals with the marquee select in the freeform canvas. + */ @observer export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps & MarqueeViewProps> { public static CurViewBounds(pinDoc: Doc, panelWidth: number, panelHeight: number) { @@ -60,9 +66,12 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps return { left: NumCast(pinDoc._freeform_panX) - panelWidth / 2 / ps, top: NumCast(pinDoc._freeform_panY) - panelHeight / 2 / ps, width: panelWidth / ps, height: panelHeight / ps }; } + static Instance: MarqueeView; + constructor(props: any) { super(props); makeObservable(this); + MarqueeView.Instance = this; } private _commandExecuted = false; @@ -430,32 +439,14 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps /** * Classifies images and assigns the labels as document fields. - * TODO: Turn into lists of labels instead of individual fields. */ @undoBatch classifyImages = action(async (e: React.MouseEvent | undefined) => { - this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG); - - const imageInfos = this._selectedDocs.map(async doc => { - const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.'); - return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 => - !hrefBase64 ? undefined : - gptImageLabel(hrefBase64).then(labels => - Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings => - ({ doc, embeddings, labels }))) ); // prettier-ignore - }); - - (await Promise.all(imageInfos)).forEach(imageInfo => { - if (imageInfo && Array.isArray(imageInfo.embeddings)) { - imageInfo.doc[DocData].data_labels = imageInfo.labels; - numberRange(3).forEach(n => { - imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]); - }); - } - }); - - if (e) { - ImageLabelHandler.Instance.displayLabelHandler(e.pageX, e.pageY); + const groupButton = DocListCast(Doc.MyLeftSidebarMenu.data).find(d => d.target === Doc.MyImageGrouper); + if (groupButton) { + this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG); + ImageLabelBoxData.Instance.setData(this._selectedDocs); + MainView.Instance.expandFlyout(groupButton); } }); @@ -464,28 +455,47 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps */ @undoBatch groupImages = action(async () => { - const labelGroups = ImageLabelHandler.Instance._labelGroups; - const labelToEmbedding = new Map<string, number[]>(); - // Create embeddings for the labels. - await Promise.all(labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding)))); - - // For each image, loop through the labels, and calculate similarity. Associate it with the - // most similar one. - this._selectedDocs.forEach(doc => { - const embedLists = numberRange(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`]))); - const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map(l => (embedding && similarity(Array.from(embedding), l)) || 0)); - const {label: mostSimilarLabelCollect} = - labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) })) - .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur, - { label: '', similarityScore: 0, }); // prettier-ignore - - numberRange(3).forEach(n => { - doc[`data_labels_embedding_${n + 1}`] = undefined; - }); - doc[DocData].data_label = mostSimilarLabelCollect; - }); - this._props.Document._type_collection = CollectionViewType.Time; - this._props.Document.pivotField = 'data_label'; + const labelGroups: string[] = ImageLabelBoxData.Instance._labelGroups; + const labelToCollection: Map<string, Doc> = new Map(); + const selectedImages = ImageLabelBoxData.Instance._docs; + + // Create new collections associated with each label and get the embeddings for the labels. + let x_offset = 0; + let y_offset = 0; + let row_count = 0; + for (const label of labelGroups) { + const newCollection = this.getCollection([], undefined, false); + newCollection._width = 900; + newCollection._height = 900; + newCollection._x = this.Bounds.left; + newCollection._y = this.Bounds.top; + newCollection._freeform_panX = this.Bounds.left + this.Bounds.width / 2; + newCollection._freeform_panY = this.Bounds.top + this.Bounds.height / 2; + newCollection._x = (newCollection._x as number) + x_offset; + newCollection._y = (newCollection._y as number) + y_offset; + x_offset += (newCollection._width as number) + 40; + row_count += 1; + if (row_count == 3) { + y_offset += (newCollection._height as number) + 40; + x_offset = 0; + row_count = 0; + } + labelToCollection.set(label, newCollection); + this._props.addDocument?.(newCollection); + console.log('added collection!'); + } + + console.log(labelToCollection); + + for (const doc of selectedImages) { + if (doc[DocData].data_label) { + Doc.AddDocToList(labelToCollection.get(doc[DocData].data_label as string)!, undefined, doc); + this._props.removeDocument?.(doc); + } + } + + //this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view. + //this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'. }); @undoBatch diff --git a/src/client/views/linking/LinkPopup.tsx b/src/client/views/linking/LinkPopup.tsx index 76a8396ff..2405e375d 100644 --- a/src/client/views/linking/LinkPopup.tsx +++ b/src/client/views/linking/LinkPopup.tsx @@ -45,7 +45,6 @@ export class LinkPopup extends React.Component<LinkPopupProps> { {/* <i></i> <input defaultValue={""} autoComplete="off" type="text" placeholder="Search for Document..." id="search-input" className="linkPopup-searchBox searchBox-input" /> */} - <SearchBox Document={Doc.MySearcher} docViewPath={returnEmptyDocViewList} diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 7a1f94948..9ff96c692 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -573,7 +573,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document if (this._props.renderDepth === 0) { appearanceItems.splice(0, 0, { description: 'Open in Lightbox', event: () => DocumentView.SetLightboxDoc(this.Document), icon: 'external-link-alt' }); } - appearanceItems.push({ description: 'Pin', event: () => this._props.pinToPres(this.Document, {}), icon: 'eye' }); + appearanceItems.push({ description: 'Pin', event: () => this._props.pinToPres(this.Document, {}), icon: 'map-pin' }); if (this.Document._layout_isFlashcard) { appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(), icon: 'id-card' }); } diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index e4b3a1b9b..1c90fae9e 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -1,5 +1,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; +import zIndex from '@mui/material/styles/zIndex'; import { Colors } from 'browndash-components'; import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction } from 'mobx'; import { observer } from 'mobx-react'; @@ -10,6 +11,7 @@ import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; +import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { Cast, ImageCast, NumCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; @@ -173,6 +175,10 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(targetDoc), this.fieldKey); } } + const layoutDoc = de.complete.docDragData?.draggedDocuments[0]; + const targetField = Doc.LayoutFieldKey(layoutDoc); + const targetDoc = layoutDoc[DocData]; + console.log(targetDoc[targetField]); added === false && e.preventDefault(); added !== undefined && e.stopPropagation(); return added; diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 1b3d963e8..4abb23404 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -264,6 +264,7 @@ export class Doc extends RefField { public static get MyUserDocView() { return DocCast(Doc.UserDoc().myUserDocView); } // prettier-ignore public static get MyDockedBtns() { return DocCast(Doc.UserDoc().myDockedBtns); } // prettier-ignore public static get MySearcher() { return DocCast(Doc.UserDoc().mySearcher); } // prettier-ignore + public static get MyImageGrouper() { return DocCast(Doc.UserDoc().myImageGrouper); } //prettier-ignore public static get MyHeaderBar() { return DocCast(Doc.UserDoc().myHeaderBar); } // prettier-ignore public static get MyLeftSidebarMenu() { return DocCast(Doc.UserDoc().myLeftSidebarMenu); } // prettier-ignore public static get MyLeftSidebarPanel() { return DocCast(Doc.UserDoc().myLeftSidebarPanel); } // prettier-ignore |