Compare commits
10 Commits
24d0aaffcd
...
1b0e4f9dd2
Author | SHA1 | Date | |
---|---|---|---|
|
1b0e4f9dd2 | ||
|
4af0874b8f | ||
|
6da57d63bb | ||
|
bef7b8e2cf | ||
|
326f3788fa | ||
|
ee266ea372 | ||
|
d9c1282d99 | ||
|
b0c70f6d17 | ||
|
7d32bba292 | ||
|
29eb059fda |
26
.vscode/launch.json
vendored
Normal file
26
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug React TSX App",
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/react-scripts",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"start"
|
||||||
|
],
|
||||||
|
"timeout": 10000,
|
||||||
|
"localRoot": "${workspaceFolder}/src",
|
||||||
|
"remoteRoot": "${workspaceFolder}/src",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"outFiles": [
|
||||||
|
"${workspaceFolder}/src/**/*.js"
|
||||||
|
],
|
||||||
|
"smartStep": true,
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
51
README.md
51
README.md
@ -1,12 +1,55 @@
|
|||||||
|
# Invoke Prompt Editor POC
|
||||||
|
|
||||||
|
This is a POC (proof-of-concept) front-end for a new prompt editor for [Invoke](https://invoke-ai.github.io/).
|
||||||
|
|
||||||
|
This will envtually be merged into the Invoke code base.
|
||||||
|
|
||||||
|
## Features and Usage
|
||||||
|
|
||||||
|
There are 2 tabs shown:
|
||||||
|
|
||||||
|
- **Prompt Composer** This is where you'll add prompt items to your composition.
|
||||||
|
- **Text Prompt** This is the original native prompt of Invoke. As you edit in the **PromptComposer**, the
|
||||||
|
text will be updated.
|
||||||
|
|
||||||
|
![The prompt composer](image-1.png)
|
||||||
|
|
||||||
|
### Prompt Library
|
||||||
|
|
||||||
|
The prompt library is indicated by the ![library book](image.png) symbol. If you click in, a prompt library will open.
|
||||||
|
|
||||||
|
To insert a prompt into the composer, click the **plus icon** (+).
|
||||||
|
|
||||||
|
To add a new prompt to the library, use the fields at the bottom.
|
||||||
|
|
||||||
|
### Prompt Composer
|
||||||
|
|
||||||
|
The prompt composer as bare prompts (called **Nugget**s).
|
||||||
|
|
||||||
|
Each **Nugget** has a score, indicated by a number beside the prompt. The number relates to the number of times
|
||||||
|
`+` or `-` is added to the prompt
|
||||||
|
|
||||||
|
**Nugget**s can be grouped into components called **Operations**. To group **Nugget**s together, ensure you're in *dnd Mode*,
|
||||||
|
and move one nugget onto another.
|
||||||
|
|
||||||
|
If you want to add a nugget to an *operation*, drag it into the *operation*.
|
||||||
|
|
||||||
|
To change the *operator*, right click on the **Operation** and select a new one.
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- Operations cannot be grouped into other **Operation**s.
|
||||||
|
- **Nugget**s must be grouped by category (e.g. "Vibes" can only be grouped with other "Vibes").
|
||||||
|
|
||||||
# Getting Started with Create React App
|
# Getting Started with Create React App
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app),
|
||||||
|
and uses `pnpm` for the package manager.
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
In the project directory, you can run:
|
In the project directory, you can run:
|
||||||
|
|
||||||
### `npm start`
|
### `pnpm start`
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
Runs the app in the development mode.\
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
@ -14,12 +57,12 @@ Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
|||||||
The page will reload if you make edits.\
|
The page will reload if you make edits.\
|
||||||
You will also see any lint errors in the console.
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
### `npm test`
|
### `pnpm test`
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
Launches the test runner in the interactive watch mode.\
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
### `npm run build`
|
### `pnpm run build`
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
Builds the app for production to the `build` folder.\
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
BIN
image-1.png
Normal file
BIN
image-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
14
package.json
14
package.json
@ -9,6 +9,7 @@
|
|||||||
"@mui/icons-material": "^5.15.10",
|
"@mui/icons-material": "^5.15.10",
|
||||||
"@mui/lab": "5.0.0-alpha.166",
|
"@mui/lab": "5.0.0-alpha.166",
|
||||||
"@mui/material": "^5.15.10",
|
"@mui/material": "^5.15.10",
|
||||||
|
"@mui/x-data-grid": "^6.19.5",
|
||||||
"@nano-sql/core": "^2.3.7",
|
"@nano-sql/core": "^2.3.7",
|
||||||
"@nanostores/react": "^0.7.2",
|
"@nanostores/react": "^0.7.2",
|
||||||
"@reduxjs/toolkit": "^2.2.1",
|
"@reduxjs/toolkit": "^2.2.1",
|
||||||
@ -24,9 +25,14 @@
|
|||||||
"nanostores": "^0.10.0",
|
"nanostores": "^0.10.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
"react-redux": "^9.1.0",
|
"react-redux": "^9.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
"react-sortablejs": "^6.1.4",
|
||||||
|
"sortable": "^2.0.0",
|
||||||
|
"sortablejs": "^1.15.2",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
@ -57,11 +63,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^4.2.4",
|
"@testing-library/jest-dom": "^4.2.4",
|
||||||
"@types/jest": "^27.5.2",
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/sortablejs": "^1.15.8",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
"jest-without-globals": "^0.0.3"
|
"jest-without-globals": "^0.0.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"!node_modules/"
|
"!node_modules/"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
151
pnpm-lock.yaml
151
pnpm-lock.yaml
@ -26,6 +26,9 @@ dependencies:
|
|||||||
'@mui/material':
|
'@mui/material':
|
||||||
specifier: ^5.15.10
|
specifier: ^5.15.10
|
||||||
version: 5.15.10(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0)
|
version: 5.15.10(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@mui/x-data-grid':
|
||||||
|
specifier: ^6.19.5
|
||||||
|
version: 6.19.5(@mui/material@5.15.10)(@mui/system@5.15.11)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0)
|
||||||
'@nano-sql/core':
|
'@nano-sql/core':
|
||||||
specifier: ^2.3.7
|
specifier: ^2.3.7
|
||||||
version: 2.3.7
|
version: 2.3.7
|
||||||
@ -71,15 +74,30 @@ dependencies:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.0(react@18.2.0)
|
version: 18.2.0(react@18.2.0)
|
||||||
|
react-dropzone:
|
||||||
|
specifier: ^14.2.3
|
||||||
|
version: 14.2.3(react@18.2.0)
|
||||||
react-redux:
|
react-redux:
|
||||||
specifier: ^9.1.0
|
specifier: ^9.1.0
|
||||||
version: 9.1.0(@types/react@18.2.57)(react@18.2.0)(redux@5.0.1)
|
version: 9.1.0(@types/react@18.2.57)(react@18.2.0)(redux@5.0.1)
|
||||||
react-scripts:
|
react-scripts:
|
||||||
specifier: 5.0.1
|
specifier: 5.0.1
|
||||||
version: 5.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.56.0)(react@18.2.0)(typescript@4.9.5)
|
version: 5.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.56.0)(react@18.2.0)(typescript@4.9.5)
|
||||||
|
react-sortablejs:
|
||||||
|
specifier: ^6.1.4
|
||||||
|
version: 6.1.4(@types/sortablejs@1.15.8)(react-dom@18.2.0)(react@18.2.0)(sortablejs@1.15.2)
|
||||||
|
sortable:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
|
sortablejs:
|
||||||
|
specifier: ^1.15.2
|
||||||
|
version: 1.15.2
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^4.9.5
|
specifier: ^4.9.5
|
||||||
version: 4.9.5
|
version: 4.9.5
|
||||||
|
uuid:
|
||||||
|
specifier: ^9.0.1
|
||||||
|
version: 9.0.1
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.1.4(@types/node@16.18.82)
|
version: 5.1.4(@types/node@16.18.82)
|
||||||
@ -94,6 +112,12 @@ devDependencies:
|
|||||||
'@types/jest':
|
'@types/jest':
|
||||||
specifier: ^27.5.2
|
specifier: ^27.5.2
|
||||||
version: 27.5.2
|
version: 27.5.2
|
||||||
|
'@types/sortablejs':
|
||||||
|
specifier: ^1.15.8
|
||||||
|
version: 1.15.8
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^9.0.8
|
||||||
|
version: 9.0.8
|
||||||
jest-without-globals:
|
jest-without-globals:
|
||||||
specifier: ^0.0.3
|
specifier: ^0.0.3
|
||||||
version: 0.0.3(jest@25.5.4)
|
version: 0.0.3(jest@25.5.4)
|
||||||
@ -3046,6 +3070,28 @@ packages:
|
|||||||
react-is: 18.2.0
|
react-is: 18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@mui/x-data-grid@6.19.5(@mui/material@5.15.10)(@mui/system@5.15.11)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-jV1ZqwyFslKqFScSn4t+xc/tNxLHOeJjz3HoeK+Wdf5t3bPM69pg/jLeg8TmOkAUY62JmQKCLVmcGWiR3AqUKQ==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@mui/material': ^5.4.1
|
||||||
|
'@mui/system': ^5.4.1
|
||||||
|
react: ^17.0.0 || ^18.0.0
|
||||||
|
react-dom: ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.23.9
|
||||||
|
'@mui/material': 5.15.10(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@mui/system': 5.15.11(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.57)(react@18.2.0)
|
||||||
|
'@mui/utils': 5.15.11(@types/react@18.2.57)(react@18.2.0)
|
||||||
|
clsx: 2.1.0
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
reselect: 4.1.8
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@nano-sql/core@2.3.7:
|
/@nano-sql/core@2.3.7:
|
||||||
resolution: {integrity: sha512-B9nniPPRhPf5Hf2cyvy72SNEg4iKQEW6pig9nwrM4DJlmOMZudifOMPBoJuK6JcTaLATIOGRPkclfosNUALnLQ==}
|
resolution: {integrity: sha512-B9nniPPRhPf5Hf2cyvy72SNEg4iKQEW6pig9nwrM4DJlmOMZudifOMPBoJuK6JcTaLATIOGRPkclfosNUALnLQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -3801,6 +3847,9 @@ packages:
|
|||||||
'@types/node': 16.18.82
|
'@types/node': 16.18.82
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/sortablejs@1.15.8:
|
||||||
|
resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
|
||||||
|
|
||||||
/@types/stack-utils@1.0.1:
|
/@types/stack-utils@1.0.1:
|
||||||
resolution: {integrity: sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==}
|
resolution: {integrity: sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -3816,6 +3865,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
|
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/uuid@9.0.8:
|
||||||
|
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/ws@8.5.10:
|
/@types/ws@8.5.10:
|
||||||
resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
|
resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -4506,6 +4559,11 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/attr-accept@2.2.2:
|
||||||
|
resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/autoprefixer@10.4.17(postcss@8.4.35):
|
/autoprefixer@10.4.17(postcss@8.4.35):
|
||||||
resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==}
|
resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@ -4887,6 +4945,10 @@ packages:
|
|||||||
resolve: 1.1.7
|
resolve: 1.1.7
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/browser-split@0.0.0:
|
||||||
|
resolution: {integrity: sha512-CNXO3AXAS1H/kOGQkPjucm1161/XoF3aVkMfujqwk85XN/D/MkQMvoB81lXyX/2rerZS+hPAYYRR3mAW05awjQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/browserslist@4.23.0:
|
/browserslist@4.23.0:
|
||||||
resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==}
|
resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==}
|
||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
@ -5064,6 +5126,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==}
|
resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/class-list@0.1.1:
|
||||||
|
resolution: {integrity: sha512-zqR0uW+VsLtyQhixBhkdQ+z6B8+Y8HTh28kdSVjJ4zTTKM7Xz2asAQSya9VI6m/34F6N6Ktm0mrchKB+E5a8Xw==}
|
||||||
|
dependencies:
|
||||||
|
indexof: 0.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/class-utils@0.3.6:
|
/class-utils@0.3.6:
|
||||||
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
|
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -5074,6 +5142,10 @@ packages:
|
|||||||
static-extend: 0.1.2
|
static-extend: 0.1.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/classnames@2.3.1:
|
||||||
|
resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/clean-css@5.3.3:
|
/clean-css@5.3.3:
|
||||||
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
|
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
|
||||||
engines: {node: '>= 10.0'}
|
engines: {node: '>= 10.0'}
|
||||||
@ -6796,6 +6868,13 @@ packages:
|
|||||||
webpack: 5.90.3
|
webpack: 5.90.3
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/file-selector@0.6.0:
|
||||||
|
resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.6.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/filelist@1.0.4:
|
/filelist@1.0.4:
|
||||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -7461,6 +7540,13 @@ packages:
|
|||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/hyperscript@1.0.7:
|
||||||
|
resolution: {integrity: sha512-dyfX683lwCsXiVUnmfnO6xji30exAUtr2yWWfCDz6FXjD+qNXwGsBKgSfFTEKNg+MArVI25ZdadfqBgsA32NMw==}
|
||||||
|
dependencies:
|
||||||
|
browser-split: 0.0.0
|
||||||
|
class-list: 0.1.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/hyphenate-style-name@1.0.4:
|
/hyphenate-style-name@1.0.4:
|
||||||
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
|
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -7536,6 +7622,10 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/indexof@0.0.1:
|
||||||
|
resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/inflight@1.0.6:
|
/inflight@1.0.6:
|
||||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -8956,6 +9046,10 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/jquery-browserify@1.8.1:
|
||||||
|
resolution: {integrity: sha512-IDMCKuU5padhYWP21juFL10BOySPnlihoX7R1dHKeCcwl/JdeO3trDbimKQdPXtQsWIdYMwkAyxQ3+ksEj1iMQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/js-tokens@4.0.0:
|
/js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@ -9811,6 +9905,10 @@ packages:
|
|||||||
es-abstract: 1.22.4
|
es-abstract: 1.22.4
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/observable@1.3.1:
|
||||||
|
resolution: {integrity: sha512-n1QLn+I5eo/4TJxdrC54mHPYwDPvCZQ9FwwM2VE/jVkXf7aodqb0XImZnLbAIeSbnsWm1BzGcwjxeKktD/rb9g==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/obuf@1.1.2:
|
/obuf@1.1.2:
|
||||||
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -11061,6 +11159,18 @@ packages:
|
|||||||
scheduler: 0.23.0
|
scheduler: 0.23.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-dropzone@14.2.3(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==}
|
||||||
|
engines: {node: '>= 10.13'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.8 || 18.0.0'
|
||||||
|
dependencies:
|
||||||
|
attr-accept: 2.2.2
|
||||||
|
file-selector: 0.6.0
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-error-overlay@6.0.11:
|
/react-error-overlay@6.0.11:
|
||||||
resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==}
|
resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -11205,6 +11315,22 @@ packages:
|
|||||||
- webpack-plugin-serve
|
- webpack-plugin-serve
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-sortablejs@6.1.4(@types/sortablejs@1.15.8)(react-dom@18.2.0)(react@18.2.0)(sortablejs@1.15.2):
|
||||||
|
resolution: {integrity: sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/sortablejs': '1'
|
||||||
|
react: '>=16.9.0'
|
||||||
|
react-dom: '>=16.9.0'
|
||||||
|
sortablejs: '1'
|
||||||
|
dependencies:
|
||||||
|
'@types/sortablejs': 1.15.8
|
||||||
|
classnames: 2.3.1
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
sortablejs: 1.15.2
|
||||||
|
tiny-invariant: 1.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
|
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
|
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -11439,6 +11565,10 @@ packages:
|
|||||||
/requires-port@1.0.0:
|
/requires-port@1.0.0:
|
||||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||||
|
|
||||||
|
/reselect@4.1.8:
|
||||||
|
resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/reselect@5.1.0:
|
/reselect@5.1.0:
|
||||||
resolution: {integrity: sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==}
|
resolution: {integrity: sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -11958,6 +12088,18 @@ packages:
|
|||||||
websocket-driver: 0.7.4
|
websocket-driver: 0.7.4
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/sortable@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VB3IABlS7TQDd2sRE5zSqW6pPqDsuduyuJZ5vcuGpR9gPQKtFrh1Y3xUAPfxhJ6djF1HYZEfXSO9mFs2eldJ3w==}
|
||||||
|
dependencies:
|
||||||
|
hyperscript: 1.0.7
|
||||||
|
jquery-browserify: 1.8.1
|
||||||
|
observable: 1.3.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/sortablejs@1.15.2:
|
||||||
|
resolution: {integrity: sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/source-list-map@2.0.1:
|
/source-list-map@2.0.1:
|
||||||
resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==}
|
resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -12541,6 +12683,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
|
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/tiny-invariant@1.2.0:
|
||||||
|
resolution: {integrity: sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/tiny-warning@1.0.3:
|
/tiny-warning@1.0.3:
|
||||||
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -12908,6 +13054,11 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/uuid@9.0.1:
|
||||||
|
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||||
|
hasBin: true
|
||||||
|
dev: false
|
||||||
|
|
||||||
/v8-to-istanbul@4.1.4:
|
/v8-to-istanbul@4.1.4:
|
||||||
resolution: {integrity: sha512-Rw6vJHj1mbdK8edjR7+zuJrpDtKIgNdAvTSAcpYfgMIw+u2dPDntD3dgN4XQFLU2/fvFQdzj+EeSGfd/jnY5fQ==}
|
resolution: {integrity: sha512-Rw6vJHj1mbdK8edjR7+zuJrpDtKIgNdAvTSAcpYfgMIw+u2dPDntD3dgN4XQFLU2/fvFQdzj+EeSGfd/jnY5fQ==}
|
||||||
engines: {node: 8.x.x || >=10.10.0}
|
engines: {node: 8.x.x || >=10.10.0}
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, fireEvent, screen } from '@testing-library/react';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
test('renders Prompt Composer tab', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
const linkElement = screen.getByText(/learn react/i);
|
const tab = screen.getByLabelText('prompt-composer-tab');
|
||||||
// @ts-ignore
|
expect(tab).toBeInTheDocument();
|
||||||
expect(linkElement).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
test('changes tab when clicked', () => {
|
||||||
|
render(<App />);
|
||||||
|
const tab = screen.getByLabelText('text-prompt-tab');
|
||||||
|
fireEvent.click(tab);
|
||||||
|
expect(screen.getByLabelText('text-area')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
101
src/App.tsx
101
src/App.tsx
@ -1,27 +1,92 @@
|
|||||||
import React from 'react';
|
import React, { ChangeEvent, useEffect } from 'react';
|
||||||
import logo from './logo.svg';
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import PromptArea from './components/PromptArea';
|
import PromptComposer from './components/PromptComposer';
|
||||||
import { Op, Operation } from './components/Operation';
|
import { Box, Tabs, Tab, Typography, Container, Paper } from '@material-ui/core';
|
||||||
import Nugget from './components/Nugget';
|
import { $composition, $library, $textComposition, Category, Composition, Library, LibraryItem, addItemToLibrary, insertIntoComposition, lassoNuggets } from './lib/prompt';
|
||||||
import { Ast } from './lib/ast';
|
import { TextPrompt } from './components/TextPrompt';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { Op } from './lib/operator';
|
||||||
|
import { v4 as uuid4 } from 'uuid';
|
||||||
|
|
||||||
function App() {
|
|
||||||
const ast = new Ast();
|
interface TabPanelProps {
|
||||||
const c = (
|
children?: React.ReactNode;
|
||||||
<div className="App">
|
index: number;
|
||||||
<PromptArea>
|
value: number;
|
||||||
<Operation initialOp={Op.AND}>
|
}
|
||||||
<Nugget text='cookie' />
|
|
||||||
<Nugget text='chocolate' />
|
function CustomTabPanel(props: TabPanelProps) {
|
||||||
</Operation>
|
const { children, value, index, ...other } = props;
|
||||||
</PromptArea>
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`simple-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`simple-tab-${index}`}
|
||||||
|
>
|
||||||
|
{value === index && (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography>{children}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(c);
|
function a11yProps(index: number) {
|
||||||
|
return {
|
||||||
|
id: `simple-tab-${index}`,
|
||||||
|
'aria-controls': `simple-tabpanel-${index}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return c;
|
function App() {
|
||||||
|
const [value, setValue] = React.useState(0);
|
||||||
|
|
||||||
|
const fillWithMockData = () => {
|
||||||
|
const libItems = [
|
||||||
|
{ id: uuid4(), prompt: "cookie", category: Category.subject },
|
||||||
|
{ id: uuid4(), prompt: "chocolate", category: Category.subject },
|
||||||
|
{ id: uuid4(), prompt: "vintage photo", category: Category.vibes },
|
||||||
|
] as Library;
|
||||||
|
const promptItems = [
|
||||||
|
{
|
||||||
|
id: uuid4(), items: [
|
||||||
|
{ id: uuid4(), item: libItems[0], score: -2 },
|
||||||
|
{ id: uuid4(), item: libItems[1], score: 1 },
|
||||||
|
], op: Op.AND,
|
||||||
|
},
|
||||||
|
{ id: uuid4(), item: libItems[2], score: 0, },
|
||||||
|
] as Composition;
|
||||||
|
|
||||||
|
$library.set(libItems);
|
||||||
|
$composition.set(promptItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fillWithMockData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (event: ChangeEvent<{}>, newValue: number) => {
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Paper>
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs value={value} onChange={handleChange} aria-label="prompt-selection-tabs">
|
||||||
|
<Tab label="Prompt Composer" {...a11yProps(0)} aria-label="prompt-composer-tab" />
|
||||||
|
<Tab label="Text Prompt" {...a11yProps(1)} aria-label="text-prompt-tab" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
<CustomTabPanel value={value} index={0}>
|
||||||
|
<PromptComposer />
|
||||||
|
</CustomTabPanel>
|
||||||
|
<CustomTabPanel value={value} index={1}>
|
||||||
|
<TextPrompt />
|
||||||
|
</CustomTabPanel>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
37
src/components/CategoryFilter.test.tsx
Normal file
37
src/components/CategoryFilter.test.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { CategoryFilter } from "./CategoryFilter";
|
||||||
|
import { $library, Category, LibraryItem } from "../lib/prompt";
|
||||||
|
|
||||||
|
const mockOnFiltered = jest.fn();
|
||||||
|
|
||||||
|
const library: LibraryItem[] = [
|
||||||
|
{ id: "1", prompt: "Hello", category: Category.style },
|
||||||
|
{ id: "2", prompt: "World", category: Category.vibes },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("CategoryFilter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
$library.set(library);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders checkboxes for each category", () => {
|
||||||
|
render(<CategoryFilter onFiltered={mockOnFiltered} />);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(checkboxes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters the library based on checked categories", async () => {
|
||||||
|
render(<CategoryFilter onFiltered={mockOnFiltered} />);
|
||||||
|
|
||||||
|
const styleCheckbox = screen.getByLabelText("style");
|
||||||
|
const vibesCheckbox = screen.getByLabelText("vibes");
|
||||||
|
|
||||||
|
userEvent.click(styleCheckbox);
|
||||||
|
userEvent.click(vibesCheckbox);
|
||||||
|
|
||||||
|
expect(mockOnFiltered).toHaveBeenCalledWith([library[0]]);
|
||||||
|
});
|
||||||
|
});
|
44
src/components/CategoryFilter.tsx
Normal file
44
src/components/CategoryFilter.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { $library, Category, LibraryItem } from "../lib/prompt";
|
||||||
|
import { useStore } from "@nanostores/react";
|
||||||
|
import { title } from "../lib/util";
|
||||||
|
|
||||||
|
export type CategoryToggle = {[key in Category]? : boolean}
|
||||||
|
|
||||||
|
interface CategoryFilterProps {
|
||||||
|
onFiltered: (filteredLibrary: LibraryItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryFilter(props: CategoryFilterProps) {
|
||||||
|
const library = useStore($library);
|
||||||
|
const cats = Object.keys(Category);
|
||||||
|
const [categoryToggle, setCategoryToggle] = useState(
|
||||||
|
Object.fromEntries(
|
||||||
|
Array(cats.length).map(
|
||||||
|
(_, i) => [cats[i] as Category, true]
|
||||||
|
)
|
||||||
|
) as CategoryToggle
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCheckboxChange = (category: Category) => {
|
||||||
|
setCategoryToggle({
|
||||||
|
...categoryToggle,
|
||||||
|
[category]: !(categoryToggle[category]),
|
||||||
|
})
|
||||||
|
props.onFiltered(library.filter((item) => categoryToggle[item.category]))
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{Object.values(Category).map((category) => (
|
||||||
|
<label key={category} onClick={() => handleCheckboxChange(category)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={categoryToggle[category]}
|
||||||
|
/>
|
||||||
|
{title(category)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +0,0 @@
|
|||||||
import { Component, ReactComponentElement, ReactInstance, ReactNode } from "react";
|
|
||||||
|
|
||||||
export type Composable = {
|
|
||||||
|
|
||||||
} & ReactNode
|
|
@ -1,4 +1,3 @@
|
|||||||
import { Composable } from "./IComposable";
|
|
||||||
import { LibraryItem as LibItem } from "../lib/prompt";
|
import { LibraryItem as LibItem } from "../lib/prompt";
|
||||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||||
import { Button } from "@material-ui/core";
|
import { Button } from "@material-ui/core";
|
||||||
@ -11,7 +10,7 @@ export interface StyleProps {
|
|||||||
export function LibraryItem(props: StyleProps) {
|
export function LibraryItem(props: StyleProps) {
|
||||||
const { item, onInsertItem } = props
|
const { item, onInsertItem } = props
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={`library-item ${item.category}`}>
|
||||||
<Button onClick={() => onInsertItem(item)} aria-label="Add">
|
<Button onClick={() => onInsertItem(item)} aria-label="Add">
|
||||||
<AddCircleOutlineOutlinedIcon/>
|
<AddCircleOutlineOutlinedIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
@ -23,5 +22,5 @@ export function LibraryItem(props: StyleProps) {
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) as Composable;
|
);
|
||||||
};
|
};
|
3
src/components/NewLibraryItem.css
Normal file
3
src/components/NewLibraryItem.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.new-item-form div {
|
||||||
|
margin: 5pt;
|
||||||
|
}
|
72
src/components/NewLibraryItem.test.tsx
Normal file
72
src/components/NewLibraryItem.test.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||||
|
import { NewLibraryItem } from './NewLibraryItem';
|
||||||
|
import { Category, addItemToLibrary, categoryHasName } from '../lib/prompt';
|
||||||
|
|
||||||
|
jest.mock('../lib/prompt', () => ({
|
||||||
|
addItemToLibrary: jest.fn(),
|
||||||
|
categoryHasName: jest.fn(),
|
||||||
|
Category: {
|
||||||
|
subject: "subject",
|
||||||
|
vibes: "vibes",
|
||||||
|
medium: "medium",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('NewLibraryItem', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(addItemToLibrary as jest.Mock).mockReset();
|
||||||
|
(categoryHasName as jest.Mock).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the form', () => {
|
||||||
|
render(<NewLibraryItem />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Subject')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Prompt')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the category', () => {
|
||||||
|
render(<NewLibraryItem />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Prompt Item Category').querySelectorAll('option')[1]);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Prompt Item Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the prompt', () => {
|
||||||
|
render(<NewLibraryItem />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Prompt'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new library item', async () => {
|
||||||
|
render(<NewLibraryItem />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Prompt Item Category').querySelectorAll('option')[1]);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Prompt Item Name')).toBeInTheDocument();
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('Prompt Item Name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('Prompt Item Text'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Create'));
|
||||||
|
|
||||||
|
expect(addItemToLibrary).toHaveBeenCalledWith({
|
||||||
|
category: Category.subject,
|
||||||
|
name: 'Test',
|
||||||
|
prompt: 'Test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
93
src/components/NewLibraryItem.tsx
Normal file
93
src/components/NewLibraryItem.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { Button, Container, FormControl, InputLabel, TextField } from "@material-ui/core";
|
||||||
|
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||||
|
import { Category, LibraryItem, addItemToLibrary, categoryHasName } from "../lib/prompt";
|
||||||
|
import { ChangeEvent, useState } from "react";
|
||||||
|
import { v4 as uuidv4 } from "uuid"
|
||||||
|
import { Stack } from "@mui/material";
|
||||||
|
import "./NewLibraryItem.css"
|
||||||
|
|
||||||
|
export interface NewLibraryItemProps {
|
||||||
|
onNewCreated?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewLibraryItem(props: NewLibraryItemProps) {
|
||||||
|
const { onNewCreated } = props;
|
||||||
|
|
||||||
|
const [category, setCategory] = useState(Category.subject);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
|
||||||
|
const handleCategoryChange = (e: any | SelectChangeEvent) => {
|
||||||
|
setCategory(e.target.value as Category);
|
||||||
|
}
|
||||||
|
const handleNameChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
}
|
||||||
|
const handlePromptChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setPrompt(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleCase = (s: string) => {
|
||||||
|
return s[0].toUpperCase() + s.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateItem = () => {
|
||||||
|
const libraryItem = {
|
||||||
|
id: uuidv4(),
|
||||||
|
category,
|
||||||
|
name: categoryHasName(category) ? name : null,
|
||||||
|
prompt,
|
||||||
|
} as LibraryItem;
|
||||||
|
addItemToLibrary(libraryItem);
|
||||||
|
setCategory(Category.subject);
|
||||||
|
setName("");
|
||||||
|
setPrompt("");
|
||||||
|
onNewCreated ? onNewCreated() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catChoices = Object.keys(Category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="new-item-form">
|
||||||
|
<FormControl>
|
||||||
|
<InputLabel id="category-select-label">Category</InputLabel>
|
||||||
|
<Select
|
||||||
|
native
|
||||||
|
labelId="category-select-label"
|
||||||
|
id="category-select"
|
||||||
|
value={category}
|
||||||
|
label="Category"
|
||||||
|
onChange={handleCategoryChange}
|
||||||
|
>
|
||||||
|
{catChoices.map(c => (
|
||||||
|
<option key={c} value={c}>{titleCase(c)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{categoryHasName(category) && (
|
||||||
|
<FormControl>
|
||||||
|
<InputLabel id="name-textfield-label">Name</InputLabel>
|
||||||
|
<TextField
|
||||||
|
itemID="name-textfield-label"
|
||||||
|
id="name-textfield"
|
||||||
|
value={name}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<FormControl>
|
||||||
|
<InputLabel id="prompt-textfield-label">Prompt</InputLabel>
|
||||||
|
<TextField
|
||||||
|
itemID="prompt-textfield-label"
|
||||||
|
id="prompt-textfield"
|
||||||
|
value={prompt}
|
||||||
|
onChange={handlePromptChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<Button variant="contained" onClick={handleCreateItem}>Create</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
@ -1,13 +1,23 @@
|
|||||||
.nugget {
|
.nugget.toplevel {
|
||||||
border: 1px solid slategray;
|
border: 1px solid slategray;
|
||||||
border-radius: 10pt;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin: 10pt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nugget > .text, .nugget > .score, .nugget.buttons {
|
.operation .nugget {
|
||||||
display: flex;
|
border: 1px solid white;
|
||||||
padding: 4pt;
|
border-radius: 2pt;
|
||||||
margin-top: 10pt;
|
display: inline-flex;
|
||||||
vertical-align: text-bottom;
|
}
|
||||||
|
|
||||||
|
.nugget.child > .text, .nugget.child > .score, .nugget.child.buttons {
|
||||||
|
padding: 4pt 2pt;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nugget.toplevel {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nugget .buttons button {
|
||||||
|
max-height: 14pt;
|
||||||
}
|
}
|
@ -1,24 +1,63 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, fireEvent, screen} from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import Nugget from './Nugget';
|
import Nugget from './Nugget';
|
||||||
|
import { Category, Nugget as NuggetType } from '../lib/prompt';
|
||||||
|
|
||||||
|
const nugget: NuggetType = {
|
||||||
|
id: '123',
|
||||||
|
item: {
|
||||||
|
id: '456',
|
||||||
|
prompt: 'This is a sample nugget',
|
||||||
|
category: Category.subject,
|
||||||
|
},
|
||||||
|
score: 10,
|
||||||
|
};
|
||||||
|
|
||||||
test('renders Nugget component', () => {
|
test('renders Nugget component', () => {
|
||||||
const result = render(<Nugget text="Hello, world!" />);
|
render(<Nugget nugget={nugget}
|
||||||
expect(result.container.querySelector(".text")?.textContent).toContain("Hello, world!");
|
onDelete={i => {}} />);
|
||||||
|
const textElement = screen.getByText(nugget.item.prompt);
|
||||||
|
expect(textElement).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updates score when up arrow is clicked', () => {
|
test('increases score when button is clicked', () => {
|
||||||
const result = render(<Nugget text="Hello, world!" initialScore={0} />);
|
const increaseScore = jest.fn();
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
const decreaseScore = jest.fn();
|
||||||
const upButton = result.container.querySelector(".incScore");
|
const { rerender } = render(
|
||||||
if (upButton) fireEvent.click(upButton);
|
<Nugget
|
||||||
expect(result.container.querySelector(".score")?.textContent).toBe("+1");
|
nugget={nugget}
|
||||||
|
onDelete={i => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const increaseButton = screen.getByLabelText('incScore');
|
||||||
|
increaseButton.click();
|
||||||
|
rerender(
|
||||||
|
<Nugget
|
||||||
|
nugget={{ ...nugget, score: nugget.score + 1 }}
|
||||||
|
onDelete={i => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// expect(increaseScore).toHaveBeenCalledTimes(1);
|
||||||
|
// expect(decreaseScore).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updates score when down arrow is clicked', () => {
|
test('decreases score when button is clicked', () => {
|
||||||
const result = render(<Nugget text="Hello, world!" initialScore={0} />);
|
const increaseScore = jest.fn();
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
const decreaseScore = jest.fn();
|
||||||
const downButton = result.container.querySelector(".decScore");
|
const { rerender } = render(
|
||||||
if (downButton) fireEvent.click(downButton);
|
<Nugget
|
||||||
expect(result.container.querySelector(".score")?.textContent).toBe("-1");
|
nugget={nugget}
|
||||||
|
onDelete={i => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const decreaseButton = screen.getByLabelText('decScore');
|
||||||
|
decreaseButton.click();
|
||||||
|
rerender(
|
||||||
|
<Nugget
|
||||||
|
nugget={{ ...nugget, score: nugget.score - 1 }}
|
||||||
|
onDelete={i => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// expect(decreaseScore).toHaveBeenCalledTimes(1);
|
||||||
|
// expect(increaseScore).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@ -1,36 +1,138 @@
|
|||||||
import { Button, ButtonGroup, Chip, Divider } from '@material-ui/core';
|
import { Button, ButtonGroup, Chip, Divider } from '@material-ui/core';
|
||||||
import React, { Component, useState } from 'react';
|
import React, { Component, DragEvent, useEffect, useState } from 'react';
|
||||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
import {KeyboardArrowUp, KeyboardArrowDown} from '@mui/icons-material';
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowUp';
|
import { $composition, Nugget as NuggetType, decreaseNuggetScore, increaseNuggetScore, togglePromptItemMute } from '../lib/prompt';
|
||||||
import {Composable} from "./IComposable"
|
|
||||||
|
|
||||||
import "./Nugget.css";
|
import "./Nugget.css";
|
||||||
import { Nugget as NuggetType, decreaseNuggetScore, increaseNuggetScore } from '../lib/prompt';
|
import "./PromptItem.css"
|
||||||
|
import { $sourceItem, cancelDrop, completeDrop, isPromptItemDropTarget, startDrag, startHoverOver } from '../store/prompt-dnd';
|
||||||
|
import { PromptItemProps } from './PromptItem';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { ArrowDownward, Delete, TextDecrease, VolumeMute, VolumeOff, VolumeUp } from '@mui/icons-material';
|
||||||
|
|
||||||
export interface NuggetProps {
|
export interface NuggetProps extends PromptItemProps {
|
||||||
nugget : NuggetType,
|
nugget: NuggetType,
|
||||||
|
isTopLevel?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Nugget(props : NuggetProps) {
|
export default function Nugget(props: NuggetProps) {
|
||||||
const {nugget} = props;
|
|
||||||
|
const { nugget,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onMouseLeave,
|
||||||
|
isTopLevel,
|
||||||
|
onDelete,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const scoreDisp = nugget.score > 0 ? "+" + nugget.score : nugget.score;
|
const scoreDisp = nugget.score > 0 ? "+" + nugget.score : nugget.score;
|
||||||
|
|
||||||
|
const sourceItem = useStore($sourceItem);
|
||||||
|
const composition = useStore($composition)
|
||||||
|
const thisId = `prompt-item-${nugget.id}`
|
||||||
|
|
||||||
|
const className = [...(nugget.muted === true ? ["muted"] : []), ...[
|
||||||
|
isTopLevel ? "toplevel" : "child",
|
||||||
|
"nugget",
|
||||||
|
"prompt-item",
|
||||||
|
]].join(" ");
|
||||||
|
|
||||||
|
console.log("nugget classname: %s", className);
|
||||||
|
|
||||||
|
const handleOnDragStart = (e: DragEvent) => {
|
||||||
|
if ("checkForDrag" in window) {
|
||||||
|
if (Math.abs((window.checkForDrag as number) - e.clientX) < 5) {
|
||||||
|
return e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDragStart ? onDragStart(nugget) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnMouseEnter = () => {
|
||||||
|
if (!sourceItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startHoverOver(nugget);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnMouseLeave = () => {
|
||||||
|
onMouseLeave ? onMouseLeave(nugget) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnDragOver = ($e: DragEvent) => {
|
||||||
|
if (!sourceItem) return;
|
||||||
|
// extract the prompt item's ID:
|
||||||
|
const targetId = $e.currentTarget.getAttribute("data-promptitem-id");
|
||||||
|
if (sourceItem.id == targetId) return;
|
||||||
|
console.log("current target id: %s", targetId);
|
||||||
|
const promptItem = composition.find(i => (targetId === i.id));
|
||||||
|
if (!promptItem) {
|
||||||
|
console.warn("Could not find promptitem with ID %s", targetId);
|
||||||
|
console.log(composition.map(c => c.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startHoverOver(promptItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnDragEnd = () => {
|
||||||
|
if (onDragEnd) onDragEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
onDelete(nugget);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouseDownCoords = (e: MouseEvent) => {
|
||||||
|
(window as any).checkForDrag = e.clientX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIncClick = () => {
|
||||||
|
console.log("decrease %s", nugget.id);
|
||||||
|
increaseNuggetScore(nugget.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDecClick = () => {
|
||||||
|
console.log("increase %s", nugget.id);
|
||||||
|
decreaseNuggetScore(nugget.id);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='nugget'>
|
<li
|
||||||
|
className={className}
|
||||||
|
id={thisId}
|
||||||
|
draggable
|
||||||
|
onDragStart={handleOnDragStart}
|
||||||
|
onDragOver={handleOnDragOver}
|
||||||
|
onDragEnd={handleOnDragEnd}
|
||||||
|
onMouseEnter={handleOnMouseEnter}
|
||||||
|
onMouseOut={handleOnMouseLeave}
|
||||||
|
data-promptitem-id={nugget.id}
|
||||||
|
>
|
||||||
|
{isTopLevel && (<span className='delete'>
|
||||||
|
<Button onClick={handleDelete}>
|
||||||
|
<Delete />
|
||||||
|
</Button>
|
||||||
|
</span>)
|
||||||
|
}
|
||||||
<span className='text'>{nugget.item.name || nugget.item.prompt}</span>
|
<span className='text'>{nugget.item.name || nugget.item.prompt}</span>
|
||||||
<Divider orientation="vertical" variant="middle" flexItem />
|
<Divider orientation="vertical" variant="middle" flexItem />
|
||||||
<span className='score'>{scoreDisp}</span>
|
<span className='score'>{scoreDisp}</span>
|
||||||
<span className='buttons'>
|
<span className='buttons'>
|
||||||
<ButtonGroup size="small" orientation='vertical'>
|
<ButtonGroup size="small" orientation='vertical'>
|
||||||
<Button onClick={() => increaseNuggetScore(nugget.id)} className='incScore'>
|
<Button onClick={handleIncClick} className='incScore' aria-label="incScore">
|
||||||
<KeyboardArrowUpIcon />
|
<KeyboardArrowUp />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => decreaseNuggetScore(nugget.id)} className='decScore'>
|
<Button onClick={handleDecClick} className='decScore' aria-label='decScore'>
|
||||||
<KeyboardArrowDownIcon />
|
<KeyboardArrowDown />
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{isTopLevel &&
|
||||||
) as Composable;
|
<span className='hide'>
|
||||||
|
<Button onClick={() => togglePromptItemMute(nugget.id)}>
|
||||||
|
{nugget.muted ? <VolumeUp /> : <VolumeOff />}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
}
|
}
|
@ -1,16 +1,39 @@
|
|||||||
.operation {
|
.operation {
|
||||||
|
border: 1px solid lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation .delete {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.operation .title {
|
.operation .title {
|
||||||
padding: 4pt;
|
position: absolute;
|
||||||
margin: 4pt;
|
background-color: black;
|
||||||
text-align: left;
|
color: white;
|
||||||
|
padding: 2pt 5pt;
|
||||||
|
transform: translate(10px, -40px)
|
||||||
}
|
}
|
||||||
|
|
||||||
.operation .nuggets {
|
.operation .nuggets {
|
||||||
display: inline-flex;
|
border-style: solid;
|
||||||
background-color: #ffdddd;
|
|
||||||
border: 1px solid coral;
|
|
||||||
border-radius: 10pt;
|
border-radius: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation.and .nuggets, .operation.joined .nuggets {
|
||||||
|
background-color: #b983a3;
|
||||||
|
border-color: #532e44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation.swapped .nuggets, .operation.swap .nuggets {
|
||||||
|
background-color: #8dacbd;
|
||||||
|
border-color: #4c6978;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation.blended .nuggets, .operation.blend .nuggets {
|
||||||
|
background-color: #a1af86;
|
||||||
|
border-color: #58663d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-icon {
|
||||||
|
padding-top: 20pt;
|
||||||
}
|
}
|
@ -1,18 +1,21 @@
|
|||||||
import { Menu, MenuItem } from "@material-ui/core";
|
import { Button, Menu, MenuItem } from "@material-ui/core";
|
||||||
import React, { Children, ReactNode } from 'react';
|
import React, { Children, DragEvent, ReactNode, useEffect } from 'react';
|
||||||
import "./Operation.css";
|
import "./Operation.css";
|
||||||
import { Op } from "../lib/operator";
|
import { Op } from "../lib/operator";
|
||||||
import { randomUUID } from "crypto";
|
import { v4 as randomUUID } from "uuid";
|
||||||
import { Composable } from "./IComposable";
|
import { $composition, Category, Operation as OperationType, changeOperationOp, togglePromptItemMute, unlassooOperation } from "../lib/prompt";
|
||||||
import { Operation as OperationType, changeOperationOp } from "../lib/prompt";
|
|
||||||
import Nugget from "./Nugget";
|
import Nugget from "./Nugget";
|
||||||
|
import { PromptItemProps } from "./PromptItem";
|
||||||
|
import { useStore } from "@nanostores/react";
|
||||||
|
import { $sourceItem, cancelDrop, completeDrop, startHoverOver } from "../store/prompt-dnd";
|
||||||
|
import { VolumeUp, VolumeOff, Delete, Add, RotateLeftOutlined, Shuffle, Repeat, ArrowOutward } from "@mui/icons-material";
|
||||||
|
|
||||||
interface OperationProps {
|
interface OperationProps extends PromptItemProps {
|
||||||
operation : OperationType
|
operation: OperationType
|
||||||
}
|
}
|
||||||
|
|
||||||
function Operation(props : OperationProps) {
|
function Operation(props: OperationProps) {
|
||||||
const {operation} = props;
|
const { operation, onDragStart, onDragOver, onDragEnd, onDrop, onMouseEnter, onDelete, onMouseLeave } = props;
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = React.useState<{
|
const [contextMenu, setContextMenu] = React.useState<{
|
||||||
mouseX: number;
|
mouseX: number;
|
||||||
@ -20,7 +23,44 @@ function Operation(props : OperationProps) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [id,] = React.useState(randomUUID);
|
const [id,] = React.useState(randomUUID);
|
||||||
|
const sourceItem = useStore($sourceItem);
|
||||||
|
const handleOnDragStart = () => {
|
||||||
|
onDragStart ? onDragStart(operation) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composition = useStore($composition);
|
||||||
|
|
||||||
|
|
||||||
|
const handleOnMouseEnter = () => {
|
||||||
|
if (!sourceItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startHoverOver(operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleOnMouseLeave = () => {
|
||||||
|
onMouseLeave ? onMouseLeave(operation) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnDragOver = ($e: DragEvent) => {
|
||||||
|
if (!sourceItem) return;
|
||||||
|
// extract the prompt item's ID:
|
||||||
|
const targetId = $e.currentTarget.getAttribute("data-promptitem-id");
|
||||||
|
if (sourceItem.id == targetId) return;
|
||||||
|
console.log("current target id: %s", targetId);
|
||||||
|
const promptItem = composition.find(i => (targetId === i.id));
|
||||||
|
if (!promptItem) {
|
||||||
|
console.warn("Could not find promptitem with ID %s", targetId);
|
||||||
|
console.log(composition.map(c => c.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startHoverOver(promptItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnDragEnd = () => {
|
||||||
|
if (onDragEnd) onDragEnd();
|
||||||
|
}
|
||||||
const handleContextMenu = (event: React.MouseEvent) => {
|
const handleContextMenu = (event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setContextMenu(
|
setContextMenu(
|
||||||
@ -40,21 +80,74 @@ function Operation(props : OperationProps) {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
onDelete(operation);
|
||||||
|
}
|
||||||
|
|
||||||
const changeOperator = (opV: string) => {
|
const changeOperator = (opV: string) => {
|
||||||
changeOperationOp(operation.id, opV as Op);
|
changeOperationOp(operation.id, opV as Op);
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const className = [...(operation.muted === true ? ["muted"] : []), ...[
|
||||||
|
"operation",
|
||||||
|
operation.op,
|
||||||
|
"prompt-item",
|
||||||
|
]].join(" ");
|
||||||
|
|
||||||
|
console.log("operation classname: %s", className);
|
||||||
|
|
||||||
|
const handleUngroup = () => {
|
||||||
|
unlassooOperation(operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryIcon = () => {
|
||||||
|
return {
|
||||||
|
[Op.AND]: (<Add />),
|
||||||
|
[Op.JOINED]: (<Add />),
|
||||||
|
[Op.SWAP]: (<Shuffle />),
|
||||||
|
[Op.SWAPPED]: (<Shuffle />),
|
||||||
|
[Op.BLEND]: (<Repeat />),
|
||||||
|
[Op.BLENDED]: (<Repeat />),
|
||||||
|
}[operation.op];
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="operation" onContextMenu={handleContextMenu}>
|
<li
|
||||||
|
draggable
|
||||||
|
onDragStart={handleOnDragStart}
|
||||||
|
onDragEnd={handleOnDragEnd}
|
||||||
|
onDragOver={handleOnDragOver}
|
||||||
|
onMouseEnter={handleOnMouseEnter}
|
||||||
|
onMouseOut={handleOnMouseLeave}
|
||||||
|
className={className}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
data-promptitem-id={operation.id}
|
||||||
|
>
|
||||||
|
<span className='delete'>
|
||||||
|
<Button onClick={handleDelete}>
|
||||||
|
<Delete />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
<div className="title">{operation.op}</div>
|
<div className="title">{operation.op}</div>
|
||||||
<div className="nuggets">
|
<div className="nuggets">
|
||||||
{
|
{
|
||||||
operation.items.map(nugget => {
|
operation.items.map((nugget, i) => {
|
||||||
return <Nugget nugget={nugget} />
|
return (
|
||||||
|
<>
|
||||||
|
<Nugget nugget={nugget} isTopLevel={false} onDelete={i => { }} />
|
||||||
|
{i < operation.items.length-1 && (<span className="op-icon">{getCategoryIcon()}</span>)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<span className='hide'>
|
||||||
|
<Button onClick={() => togglePromptItemMute(operation.id)}>
|
||||||
|
{operation.muted ? <VolumeUp /> : <VolumeOff />}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
<Menu
|
<Menu
|
||||||
open={contextMenu !== null}
|
open={contextMenu !== null}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
@ -70,9 +163,12 @@ function Operation(props : OperationProps) {
|
|||||||
<MenuItem onClick={() => changeOperator(v)}>{v}</MenuItem>
|
<MenuItem onClick={() => changeOperator(v)}>{v}</MenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<MenuItem onClick={handleUngroup}>
|
||||||
|
Ungroup
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</li>
|
||||||
) as Composable;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Operation, Op };
|
export { Operation, Op };
|
@ -1,5 +0,0 @@
|
|||||||
.add-button {
|
|
||||||
position: absolute;
|
|
||||||
right: 10pt;
|
|
||||||
top: 10pt;
|
|
||||||
}
|
|
@ -1,50 +0,0 @@
|
|||||||
import { Button } from '@material-ui/core';
|
|
||||||
import Masonry from '@mui/lab/Masonry';
|
|
||||||
import Nugget from './Nugget';
|
|
||||||
import { Operation } from './Operation';
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import "./PromptArea.css";
|
|
||||||
import { PromptLibrary } from './PromptLibrary';
|
|
||||||
import React from 'react';
|
|
||||||
import { $composition, $slottedComposition, LibraryItem, insertIntoComposition } from '../lib/prompt';
|
|
||||||
import { Category } from '@mui/icons-material';
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
|
|
||||||
type Composable = (typeof Nugget) | (typeof Operation);
|
|
||||||
|
|
||||||
export default function PromptArea({ children }: { children?: any }) {
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const handleClickOpen = () => {
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (value: string) => {
|
|
||||||
setOpen(false);
|
|
||||||
// setSelectedValue(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnInsertItem = (item: LibraryItem) => {
|
|
||||||
insertIntoComposition(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slottedComposition = useStore($slottedComposition);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button className="add-button">
|
|
||||||
<AddIcon />
|
|
||||||
<PromptLibrary
|
|
||||||
open={open}
|
|
||||||
onInsertItem={handleOnInsertItem}
|
|
||||||
></PromptLibrary>
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
<Masonry columns={Object.keys(Category).length} spacing={2} sequential>
|
|
||||||
{slottedComposition.map(nugget => {
|
|
||||||
|
|
||||||
})}
|
|
||||||
</Masonry>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
5
src/components/PromptComposer.css
Normal file
5
src/components/PromptComposer.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.composer-main {
|
||||||
|
padding-top: 30pt;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
margin: 10pt;
|
||||||
|
}
|
201
src/components/PromptComposer.tsx
Normal file
201
src/components/PromptComposer.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { Box, Button, ButtonGroup, Container, Paper, Snackbar, Typography } from '@material-ui/core';
|
||||||
|
import Masonry from '@mui/lab/Masonry';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import "./PromptComposer.css";
|
||||||
|
import { PromptLibrary } from './PromptLibrary';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { $composition, $library, $slottedComposition, LibraryItem, PromptItem, addToOperation, insertIntoComposition, itemIsNugget, itemIsOperation, lassoNuggets, Composition, _setComposition, removeItemFromLibrary, removeFromComposition, removeNuggetFromOperation } from '../lib/prompt';
|
||||||
|
import { BackHand, Book, Category, DragHandle, LibraryBooks, MouseSharp, Score, Sort } from '@mui/icons-material';
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import Nugget from './Nugget';
|
||||||
|
import { Stack, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||||
|
import { Op, Operation } from './Operation';
|
||||||
|
import { EditorMode, PromptItemProps } from './PromptItem';
|
||||||
|
import { ReactSortable } from "react-sortablejs";
|
||||||
|
import { $dragDropState, $dropCandidate, $isDragInProgress, $sourceItem, CategoryMismatchError, completeDrop, endHoverOver, startDrag, startHoverOver } from '../store/prompt-dnd';
|
||||||
|
|
||||||
|
export interface PromptComposerProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PromptComposer(props: PromptComposerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClickOpen = () => {
|
||||||
|
if (!open) { setOpen(true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (open) { setOpen(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const composition = useStore($composition);
|
||||||
|
|
||||||
|
// const [composition, setComposition] = useState(compStore);
|
||||||
|
|
||||||
|
const handleOnInsertItem = (item: LibraryItem) => {
|
||||||
|
console.log("INSERT %x INTO %s", item, composition)
|
||||||
|
insertIntoComposition(item);
|
||||||
|
console.log(composition)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnDeleteItem = (item: LibraryItem) => {
|
||||||
|
removeItemFromLibrary(item);
|
||||||
|
// also remove from the prompts
|
||||||
|
composition.filter(c => {
|
||||||
|
return (!("op" in c)) && c.item.id === item.id
|
||||||
|
}).forEach((c) => {
|
||||||
|
removeFromComposition(c);
|
||||||
|
});
|
||||||
|
// and from any operation
|
||||||
|
composition.filter(c => {
|
||||||
|
return "op" in c && c.items.find(o => o.item.id === item.id);
|
||||||
|
}).forEach((o) => {
|
||||||
|
if (!("op" in o)) return;
|
||||||
|
o.items.forEach((i) => {
|
||||||
|
if (i.item.id === item.id) removeNuggetFromOperation(o, i);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const [doSort, setDoSort] = useState(false);
|
||||||
|
|
||||||
|
const [editMode, setEditMode] = useState("dnd" as EditorMode);
|
||||||
|
|
||||||
|
const [error, setError] = useState(null as Error | null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param promptItem The prompt item that we're rendering
|
||||||
|
* @param key The key
|
||||||
|
* @returns Either a Nugget or an Operation component, relating to their native types.
|
||||||
|
*/
|
||||||
|
const promptItemFactory = (promptItem: PromptItem, key: string) => {
|
||||||
|
|
||||||
|
// These callbacks are mostly for drag-n-drop functionality.
|
||||||
|
// they will be different based on whether the source or target
|
||||||
|
// is either a Nugget or an Operation.
|
||||||
|
const callbacks = {
|
||||||
|
onDragStart: (item: PromptItem) => {
|
||||||
|
if (editMode !== "dnd") return;
|
||||||
|
if (itemIsNugget(promptItem)) {
|
||||||
|
startDrag(item);
|
||||||
|
}
|
||||||
|
// TODO: operation
|
||||||
|
},
|
||||||
|
onDrop: (item: PromptItem) => {
|
||||||
|
if (editMode !== "dnd") return;
|
||||||
|
const dnd = useStore($dragDropState);
|
||||||
|
const isDragInProgress = useStore($isDragInProgress);
|
||||||
|
const dropCandidate = useStore($dropCandidate);
|
||||||
|
const sourceItem = useStore($sourceItem);
|
||||||
|
if (!(sourceItem && dropCandidate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (itemIsNugget(dropCandidate) && itemIsNugget(sourceItem)) {
|
||||||
|
// TODO: show a pop-up to select the operator.
|
||||||
|
lassoNuggets(dropCandidate.id, sourceItem.id, Op.AND);
|
||||||
|
}
|
||||||
|
if (itemIsNugget(sourceItem) && itemIsOperation(dropCandidate)) {
|
||||||
|
addToOperation(sourceItem.id, dropCandidate.id);
|
||||||
|
}
|
||||||
|
completeDrop();
|
||||||
|
},
|
||||||
|
onDragEnd: () => {
|
||||||
|
try {
|
||||||
|
completeDrop();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CategoryMismatchError) {
|
||||||
|
setError(err);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMouseEnter: (item: PromptItem) => {
|
||||||
|
},
|
||||||
|
onMouseLeave: (item: PromptItem) => {
|
||||||
|
if (editMode !== "dnd") return;
|
||||||
|
endHoverOver();
|
||||||
|
},
|
||||||
|
onDelete: (item: PromptItem) => {
|
||||||
|
if (editMode !== "dnd") return;
|
||||||
|
removeFromComposition(item);
|
||||||
|
},
|
||||||
|
} as PromptItemProps;
|
||||||
|
|
||||||
|
return ("op" in promptItem ?
|
||||||
|
<Operation operation={promptItem} key={key} {...callbacks} />
|
||||||
|
: <Nugget nugget={promptItem} key={key} isTopLevel={true} {...callbacks} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setComposition(c: Composition) {
|
||||||
|
console.log("updated composition: %x", c)
|
||||||
|
_setComposition(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetEditMode = (
|
||||||
|
event: React.MouseEvent<HTMLElement>,
|
||||||
|
mode: EditorMode | null,
|
||||||
|
) => {
|
||||||
|
if (mode) setEditMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleErrorSnackbarClose = () => {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Container>
|
||||||
|
<Stack direction="row" style={{ padding: "4pt" }} >
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button onClick={handleClickOpen} >
|
||||||
|
<LibraryBooks />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={editMode}
|
||||||
|
exclusive
|
||||||
|
onChange={handleSetEditMode}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<ToggleButton value="dnd" aria-label="drag-n-drop" size="large" style={{ padding: "4pt" }}>
|
||||||
|
<BackHand />
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="sort" aria-label='score' style={{ padding: "4pt" }}>
|
||||||
|
<Sort />
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<Typography>
|
||||||
|
<strong>{editMode} Mode</strong> enabled
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
<Container className="composer-main">
|
||||||
|
{editMode == "sort" ? (
|
||||||
|
<ReactSortable list={composition} setList={setComposition}>
|
||||||
|
{composition.map(c => promptItemFactory(c, `item-${c.id}`))}
|
||||||
|
</ReactSortable>
|
||||||
|
) : composition.map(c => promptItemFactory(c, `item-${c.id}`))
|
||||||
|
}
|
||||||
|
</Container>
|
||||||
|
<PromptLibrary
|
||||||
|
open={open}
|
||||||
|
onDeleteItem={handleOnDeleteItem}
|
||||||
|
onInsertItem={handleOnInsertItem}
|
||||||
|
onClose={handleClose} />
|
||||||
|
<Snackbar
|
||||||
|
message={error?.message}
|
||||||
|
open={error !== null}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={handleErrorSnackbarClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
23
src/components/PromptItem.css
Normal file
23
src/components/PromptItem.css
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
.prompt-item .drag-target-highlight {
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item .drag-target-hover {
|
||||||
|
border: 1px solid blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item.muted, .operation.muted .nugget {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 5pt;
|
||||||
|
padding: 2pt 0pt;
|
||||||
|
margin: 2pt 4pt;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item.toplevel {
|
||||||
|
height: 55px;
|
||||||
|
}
|
25
src/components/PromptItem.tsx
Normal file
25
src/components/PromptItem.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import {PromptItem as PIType} from "../lib/prompt"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: Drag-n-drop rules!
|
||||||
|
*
|
||||||
|
* - Nuggets can be dragged and dropped into...
|
||||||
|
* - another nugget
|
||||||
|
* - when this happens, an Operation is created.
|
||||||
|
* - an operation
|
||||||
|
* - when this happens, the Nugget is added to the operation.
|
||||||
|
* - Operations can be dragged, but only to reorder.
|
||||||
|
* - Nuggets can also be dragged to be re-ordered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PromptItemProps {
|
||||||
|
onDragStart?: (item : PIType) => void,
|
||||||
|
onDragEnd?: () => void,
|
||||||
|
onDragOver?: (item: PIType) => void,
|
||||||
|
onDrop?: (item : PIType) => void,
|
||||||
|
onMouseEnter?: (item : PIType) => void,
|
||||||
|
onMouseLeave?: (item : PIType) => void,
|
||||||
|
onDelete : (item : PIType) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorMode = "dnd" | "sort" | "score"
|
18
src/components/PromptLibrary.css
Normal file
18
src/components/PromptLibrary.css
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.prompt-library-dialog .categories div {
|
||||||
|
display: inline;
|
||||||
|
margin: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.library-item button, .library-item span {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-item-form {
|
||||||
|
border: 1px solid blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
@ -3,8 +3,18 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { PromptLibrary } from './PromptLibrary';
|
import { PromptLibrary } from './PromptLibrary';
|
||||||
import { SimpleDialogProps } from './PromptLibrary';
|
import { SimpleDialogProps } from './PromptLibrary';
|
||||||
import { Category, LibraryItem as LibItemType } from '../lib/prompt';
|
import {
|
||||||
|
Category, LibraryItem as LibItemType,
|
||||||
|
Library as LibraryType,
|
||||||
|
Composition as CompositionType,
|
||||||
|
$library,
|
||||||
|
$composition,
|
||||||
|
addItemToLibrary,
|
||||||
|
insertIntoComposition,
|
||||||
|
} from '../lib/prompt';
|
||||||
|
import {Op} from "../lib/operator"
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
const mockOnAddItem = jest.fn();
|
const mockOnAddItem = jest.fn();
|
||||||
const mockItem: LibItemType = {
|
const mockItem: LibItemType = {
|
||||||
@ -15,19 +25,53 @@ const mockItem: LibItemType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockOnClose = jest.fn();
|
const mockOnClose = jest.fn();
|
||||||
|
const mockOnDeleteItem = jest.fn();
|
||||||
|
|
||||||
const mockOpen: boolean = true;
|
const mockOpen: boolean = true;
|
||||||
|
|
||||||
const mockProps: SimpleDialogProps = {
|
const mockProps: SimpleDialogProps = {
|
||||||
open: mockOpen,
|
open: mockOpen,
|
||||||
onInsertItem: mockOnAddItem,
|
onInsertItem: mockOnAddItem,
|
||||||
|
onClose: mockOnClose,
|
||||||
|
onDeleteItem: mockOnDeleteItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockLibrary: LibraryType = [
|
||||||
|
{ id: randomUUID(), name: "Name1", prompt: "Prompt1", category: Category.subject },
|
||||||
|
{ id: randomUUID(), name: "Name2", prompt: "Prompt2", category: Category.style },
|
||||||
|
{ id: randomUUID(), name: "Name3", prompt: "Prompt3", category: Category.vibes },
|
||||||
|
{ id: randomUUID(), name: "Name4", prompt: "Prompt4", category: Category.medium },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockComposition: CompositionType = [
|
||||||
|
{ id: randomUUID(), item: mockLibrary[0], score: 0 },
|
||||||
|
{ id: randomUUID(), item: mockLibrary[1], score: 0 },
|
||||||
|
{ id: randomUUID(), item: mockLibrary[2], score: 0 },
|
||||||
|
{ id: randomUUID(), item: mockLibrary[3], score: 0 },
|
||||||
|
{
|
||||||
|
id: randomUUID(), op: Op.AND, items: [
|
||||||
|
{ id: randomUUID(), item: mockLibrary[0], score: 0 },
|
||||||
|
{ id: randomUUID(), item: mockLibrary[1], score: 0 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// clear out the library and composition
|
||||||
|
$library.set([]);
|
||||||
|
$composition.set([]);
|
||||||
|
// insert the items
|
||||||
|
mockLibrary.forEach(item => {
|
||||||
|
addItemToLibrary(item);
|
||||||
|
insertIntoComposition(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('renders PromptLibrary with add button', () => {
|
test('renders PromptLibrary with add button', () => {
|
||||||
render(<PromptLibrary {...mockProps} />);
|
render(<PromptLibrary {...mockProps} />);
|
||||||
const addButton = screen.getByLabelText('Add');
|
const addButton = screen.getAllByLabelText('Add').at(0);
|
||||||
const itemName = screen.getByText((content, element) => {
|
const itemName = screen.getByText((content, _) => {
|
||||||
return content.includes(mockItem.name as string);
|
return content.includes(mockLibrary[0].prompt as string);
|
||||||
});
|
});
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
expect(addButton).toBeInTheDocument();
|
expect(addButton).toBeInTheDocument();
|
||||||
@ -37,7 +81,9 @@ test('renders PromptLibrary with add button', () => {
|
|||||||
|
|
||||||
test('calls onAddItem when add button is clicked', async () => {
|
test('calls onAddItem when add button is clicked', async () => {
|
||||||
render(<PromptLibrary {...mockProps} />);
|
render(<PromptLibrary {...mockProps} />);
|
||||||
const addButton = screen.getByLabelText('Add');
|
const addButton = screen.getAllByLabelText('Add').at(0);
|
||||||
await userEvent.click(addButton);
|
act(() => {
|
||||||
expect(mockOnAddItem).toHaveBeenCalledWith(mockItem);
|
userEvent.click(addButton as HTMLElement);
|
||||||
|
})
|
||||||
|
expect(mockOnAddItem).toHaveBeenCalledWith(mockLibrary[0]);
|
||||||
});
|
});
|
||||||
|
@ -1,68 +1,106 @@
|
|||||||
import { Checkbox, Dialog, DialogTitle } from "@material-ui/core";
|
import { Button, Dialog, DialogActions, DialogTitle } from "@material-ui/core";
|
||||||
import { LibraryItem as LibItemType, $library, Category } from "../lib/prompt";
|
import { LibraryItem as LibItemType, $library, Category, LibraryItem, insertIntoComposition } from "../lib/prompt";
|
||||||
import { LibraryItem } from "./LibraryItem";
|
import { MouseEvent, useMemo, useState } from "react";
|
||||||
import { ChangeEvent } from "react";
|
|
||||||
import { useStore } from "@nanostores/react";
|
import { useStore } from "@nanostores/react";
|
||||||
|
import { NewLibraryItem } from "./NewLibraryItem";
|
||||||
|
import { DataGrid, GridApi, GridColDef, GridColTypeDef } from '@mui/x-data-grid';
|
||||||
|
import "./PromptLibrary.css"
|
||||||
|
import { title } from "../lib/util";
|
||||||
|
import { Add, Delete } from "@mui/icons-material";
|
||||||
|
|
||||||
export interface SimpleDialogProps {
|
export interface SimpleDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
// onClose: (composable: Composable) => void,
|
onClose: () => void,
|
||||||
// onAddItem: (item: LibItemType) => void,
|
// onAddItem: (item: LibItemType) => void,
|
||||||
onInsertItem: (item: LibItemType) => void,
|
onInsertItem: (item: LibItemType) => void,
|
||||||
|
onDeleteItem: (item: LibItemType) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
function title(text: string) {
|
|
||||||
return (!text.length) ? "" : ((text.length === 1) ? text.toUpperCase() : text[0].toUpperCase() + text.substring(1).toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const hide = ($el: Element) => {
|
|
||||||
if (!$el.classList.contains("hidden")) $el.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = ($el: Element) => {
|
|
||||||
if (!$el.classList.contains("hidden")) $el.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PromptLibrary(props: SimpleDialogProps) {
|
export function PromptLibrary(props: SimpleDialogProps) {
|
||||||
const { open, onInsertItem } = props;
|
const { open, onInsertItem, onClose, onDeleteItem } = props;
|
||||||
|
|
||||||
const library = useStore($library);
|
const library = useStore($library);
|
||||||
|
|
||||||
const handleOnAddItem = (item: LibItemType) => {
|
const [doCreate, setDoCreate] = useState(false);
|
||||||
// onAddItem(item);
|
|
||||||
|
const [visibleCategories, setVisibleCategories] = useState(Object.keys(Category) as Category[]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnInsertItem = (item: LibItemType) => {
|
const handleOnNewCreated = () => {
|
||||||
onInsertItem(item);
|
setDoCreate(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterCat = (catKey: string, catVal: string, v: ChangeEvent<HTMLInputElement>) => {
|
const categoryChoices = Object.keys(Category);
|
||||||
const isChecked = v.target.value === '1';
|
|
||||||
document.querySelectorAll(`.category-${catVal}`).forEach($el => {
|
const filteredLibrary = useMemo(() => {
|
||||||
if (isChecked) show($el)
|
return library.filter(item => visibleCategories.includes(item.category));
|
||||||
else hide($el)
|
}, [library, visibleCategories]);
|
||||||
});
|
|
||||||
}
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "insertPrompt", width: 50, renderCell: (params) => {
|
||||||
|
const handleClick = ($e: MouseEvent<any>) => {
|
||||||
|
console.log(`clicked!`);
|
||||||
|
$e.stopPropagation();
|
||||||
|
const libItem = library.find(l => l.id === params.id) as LibItemType;
|
||||||
|
console.log("Inserting %o into composition", libItem);
|
||||||
|
if (libItem) onInsertItem(libItem);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button onClick={handleClick}>
|
||||||
|
<Add />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}, headerName: "",
|
||||||
|
},
|
||||||
|
{ field: 'name', headerName: 'Name', width: 150 },
|
||||||
|
{ field: 'prompt', headerName: 'Prompt', width: 250 },
|
||||||
|
{ field: 'category', headerName: 'Category', width: 150 },
|
||||||
|
{ field: "delete", headerName: "headerName", width: 50, renderCell: (params) => {
|
||||||
|
const handleClick = ($e: MouseEvent<any>) => {
|
||||||
|
$e.stopPropagation();
|
||||||
|
const libItem = library.find(l => l.id === params.id) as LibItemType;
|
||||||
|
if (libItem) onDeleteItem(libItem);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button onClick={handleClick}>
|
||||||
|
<Delete />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = filteredLibrary.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || "",
|
||||||
|
prompt: item.prompt,
|
||||||
|
category: title(item.category),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open}>
|
<Dialog
|
||||||
|
hideBackdrop
|
||||||
|
disableEnforceFocus
|
||||||
|
fullWidth={true}
|
||||||
|
maxWidth="lg"
|
||||||
|
className="prompt-library-dialog"
|
||||||
|
onClose={handleClose}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
<DialogTitle>Prompt Library</DialogTitle>
|
<DialogTitle>Prompt Library</DialogTitle>
|
||||||
<div className="categories">
|
|
||||||
{Object.entries(Category).map(([catKey, catVal]) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Checkbox onChange={v => filterCat(catKey, catVal, v)} />
|
|
||||||
<span>{title(catVal)}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
{
|
<DataGrid rows={rows} columns={columns} style={{display: "block", width: "fit-contents"}} />
|
||||||
library?.map(item => <LibraryItem item={item} onInsertItem={handleOnInsertItem} />)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
<NewLibraryItem onNewCreated={handleOnNewCreated} />
|
||||||
|
<DialogActions>
|
||||||
|
<Button autoFocus onClick={handleClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
6
src/components/TextPrompt.css
Normal file
6
src/components/TextPrompt.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.text-prompt {
|
||||||
|
width: 75%;
|
||||||
|
height: 75%;
|
||||||
|
padding: 10pt;
|
||||||
|
margin: 4pt;
|
||||||
|
}
|
16
src/components/TextPrompt.tsx
Normal file
16
src/components/TextPrompt.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Container, TextareaAutosize } from "@material-ui/core";
|
||||||
|
import { $textComposition } from "../lib/prompt";
|
||||||
|
import "./TextPrompt.css";
|
||||||
|
import { useStore } from "@nanostores/react";
|
||||||
|
|
||||||
|
export function TextPrompt() {
|
||||||
|
const text = useStore($textComposition);
|
||||||
|
return (
|
||||||
|
<Container aria-label="text-area">
|
||||||
|
<TextareaAutosize
|
||||||
|
className="text-prompt"
|
||||||
|
defaultValue={text}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
@ -3,17 +3,13 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import reportWebVitals from './reportWebVitals';
|
import reportWebVitals from './reportWebVitals';
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import store from "./store"
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Provider store={store}>
|
<App />
|
||||||
<App />
|
|
||||||
</Provider >
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { v4 as randomUUID } from "uuid";
|
||||||
import { Op } from "./operator";
|
import { Op } from "./operator";
|
||||||
import { createSlice } from '@reduxjs/toolkit'
|
import { createSlice } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
export enum Op {
|
export enum Op {
|
||||||
JOINED = "joined",
|
JOINED = "joined",
|
||||||
AND = "and",
|
AND = "and",
|
||||||
SWAPPED = "swaped",
|
SWAPPED = "swapped",
|
||||||
SWAP = "swap",
|
SWAP = "swap",
|
||||||
BLENDED = "blended",
|
BLENDED = "blended",
|
||||||
BLEND = "blend",
|
BLEND = "blend",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { v4 as randomUUID } from "uuid";
|
||||||
import { Op } from "./operator";
|
import { Op } from "./operator";
|
||||||
import {
|
import {
|
||||||
Library as LibraryType,
|
Library as LibraryType,
|
||||||
@ -11,7 +11,11 @@ import {
|
|||||||
Category,
|
Category,
|
||||||
removeFromComposition,
|
removeFromComposition,
|
||||||
increaseNuggetScore,
|
increaseNuggetScore,
|
||||||
decreaseNuggetScore, changeOperationOp, nuggetToText, operationToText, textComposition,
|
decreaseNuggetScore,
|
||||||
|
changeOperationOp,
|
||||||
|
nuggetToText,
|
||||||
|
operationToText,
|
||||||
|
$textComposition,
|
||||||
$slottedComposition,
|
$slottedComposition,
|
||||||
Operation,
|
Operation,
|
||||||
Nugget,
|
Nugget,
|
||||||
@ -37,7 +41,11 @@ const mockComposition: CompositionType = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeEach(() => {
|
||||||
|
// clear out the library and composition
|
||||||
|
$library.set([]);
|
||||||
|
$composition.set([]);
|
||||||
|
// insert the items
|
||||||
mockLibrary.forEach(item => {
|
mockLibrary.forEach(item => {
|
||||||
addItemToLibrary(item);
|
addItemToLibrary(item);
|
||||||
insertIntoComposition(item);
|
insertIntoComposition(item);
|
||||||
@ -65,13 +73,17 @@ test("removeFromComposition", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("increaseNuggetScore", () => {
|
test("increaseNuggetScore", () => {
|
||||||
increaseNuggetScore(mockComposition[0].id, 2);
|
const comp = $composition.get();
|
||||||
expect((mockComposition[0] as Nugget).score).toBe(2);
|
increaseNuggetScore(comp[0].id, 2);
|
||||||
|
const comp2 = $composition.get();
|
||||||
|
expect((comp2[0] as Nugget).score).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("decreaseNuggetScore", () => {
|
test("decreaseNuggetScore", () => {
|
||||||
decreaseNuggetScore(mockComposition[0].id, 2);
|
const comp = $composition.get();
|
||||||
expect((mockComposition[0] as Nugget).score).toBe(-2);
|
decreaseNuggetScore(comp[1].id, -2);
|
||||||
|
const comp2 = $composition.get();
|
||||||
|
expect((comp2[1] as Nugget).score).toBe(-2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changeOperationOp", () => {
|
test("changeOperationOp", () => {
|
||||||
@ -80,27 +92,31 @@ test("changeOperationOp", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("nuggetToText", () => {
|
test("nuggetToText", () => {
|
||||||
expect(nuggetToText({ id: randomUUID(), item: mockLibrary[0], score: 0 })).toBe("(Prompt1)");
|
expect(nuggetToText({ id: randomUUID(), item: mockLibrary[0], score: 0 })).toBe("Prompt1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("operationToText", () => {
|
test("operationToText", () => {
|
||||||
expect(operationToText({
|
expect(operationToText({
|
||||||
id: randomUUID(), op: Op.AND, items: [
|
id: randomUUID(), op: Op.AND, items: [
|
||||||
{ id: randomUUID(), item: mockLibrary[0], score: 0 },
|
{ id: randomUUID(), item: mockLibrary[0], score: 0 },
|
||||||
{ id: randomUUID(), item: mockLibrary[1], score: 0 },
|
{ id: randomUUID(), item: mockLibrary[1], score: 2 },
|
||||||
]
|
]
|
||||||
})).toBe("(Prompt1, Prompt2).concat()");
|
})).toBe("(Prompt1, Prompt2++).and()");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("textComposition", () => {
|
test("textComposition", () => {
|
||||||
expect(textComposition).toBe("(Prompt1)(Prompt2)(Prompt3)(Prompt4)(Prompt1, Prompt2).concat()");
|
$composition.set(mockComposition);
|
||||||
|
expect($textComposition.get()).toBe("Prompt1, Prompt2, Prompt3, Prompt4, (Prompt1, Prompt2).and()");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("$slottedComposition", () => {
|
test("$slottedComposition", () => {
|
||||||
expect($slottedComposition).toEqual([
|
$composition.set(mockComposition);
|
||||||
[mockComposition[0], mockComposition[1], mockComposition[2], mockComposition[3]],
|
const comp = $composition.get();
|
||||||
[],
|
const slotted = $slottedComposition.get();
|
||||||
[],
|
expect(slotted).toEqual([
|
||||||
[],
|
[comp[0], comp[4]],
|
||||||
|
[comp[1]],
|
||||||
|
[comp[2]],
|
||||||
|
[comp[3]],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { v4 as randomUUID, v4 as uuidv4 } from "uuid";
|
||||||
import { Op } from "./operator";
|
import { Op } from "./operator";
|
||||||
import { atom, computed } from "nanostores";
|
import { atom, computed } from "nanostores";
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ type IdAble = {
|
|||||||
id: Id,
|
id: Id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only vibes and styles will have a name.
|
||||||
export enum Category {
|
export enum Category {
|
||||||
subject = "subject",
|
subject = "subject",
|
||||||
style = "style",
|
style = "style",
|
||||||
@ -15,6 +16,10 @@ export enum Category {
|
|||||||
medium = "medium",
|
medium = "medium",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function categoryHasName(cat: Category) {
|
||||||
|
return (cat === Category.style || cat === Category.vibes)
|
||||||
|
}
|
||||||
|
|
||||||
const N_CATEGORIES = Object.keys(Category).length;
|
const N_CATEGORIES = Object.keys(Category).length;
|
||||||
|
|
||||||
export function catI(c: Category | string): number {
|
export function catI(c: Category | string): number {
|
||||||
@ -30,21 +35,33 @@ export type LibraryItem = {
|
|||||||
|
|
||||||
export type Library = Array<LibraryItem>;
|
export type Library = Array<LibraryItem>;
|
||||||
|
|
||||||
export type Nugget = IdAble & {
|
export type Muteable = {
|
||||||
|
muted?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nugget = IdAble & Muteable & {
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
score: number,
|
score: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Operation = IdAble & {
|
export type Operation = IdAble & Muteable & {
|
||||||
op: Op,
|
op: Op,
|
||||||
items: Array<Nugget>
|
items: Array<Nugget>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptItem = Operation | Nugget
|
export type PromptItem = Operation | Nugget
|
||||||
|
|
||||||
|
export function itemIsOperation(item: PromptItem): boolean {
|
||||||
|
return "op" in item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemIsNugget(item: PromptItem): boolean {
|
||||||
|
return !itemIsOperation(item);
|
||||||
|
}
|
||||||
|
|
||||||
export type Composition = Array<PromptItem>;
|
export type Composition = Array<PromptItem>;
|
||||||
|
|
||||||
export const $library = atom<Library>([])
|
export const $library = atom<Library>([]);
|
||||||
|
|
||||||
export function addItemToLibrary(item: LibraryItem) {
|
export function addItemToLibrary(item: LibraryItem) {
|
||||||
$library.set([
|
$library.set([
|
||||||
@ -53,39 +70,51 @@ export function addItemToLibrary(item: LibraryItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function removeItemFromLibrary(item: LibraryItem) {
|
export function removeItemFromLibrary(item: LibraryItem) {
|
||||||
$library.set($library.get().filter(i => i.id != item.id));
|
$library.set($library.get().filter(i => (i.id !== item.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const $composition = atom<Composition>([])
|
export const $composition = atom<Composition>([]);
|
||||||
|
|
||||||
export function insertIntoComposition(item: LibraryItem) {
|
export function insertIntoComposition(item: LibraryItem) {
|
||||||
$composition.set([
|
$composition.set([
|
||||||
...$composition.get(),
|
...$composition.get(),
|
||||||
{ id: randomUUID(), item, score: 0 } as Nugget,
|
{ item, id: randomUUID(), score: 0, muted: false } as Nugget,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeFromComposition(item: PromptItem) {
|
export function removeFromComposition(item: PromptItem) {
|
||||||
$composition.set([
|
$composition.set([
|
||||||
...$composition.get().filter(i => i.id === item.id)
|
...$composition.get().filter(i => (i.id !== item.id))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function increaseNuggetScore(nuggetId: Id, amount: number = 1) {
|
function nuggetDelta(nuggetId: Id, delta: number) {
|
||||||
$composition.set([
|
$composition.set($composition.get().map(item => {
|
||||||
...$composition.get().map(item => {
|
if ((item.id === nuggetId) && ("score" in item)) {
|
||||||
return (item.id == nuggetId && "score" in item) ? { ...item, score: item.score + amount } : item;
|
return { ...item, score: item.score + delta };
|
||||||
}
|
}
|
||||||
),
|
if ("op" in item) {
|
||||||
]);
|
return {
|
||||||
|
...item, items: item.items.map(
|
||||||
|
nug => {
|
||||||
|
return {
|
||||||
|
...nug,
|
||||||
|
score: nug.score + (nuggetId === nug.id ? delta : 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
export function decreaseNuggetScore(nuggetId: Id, amount: number = 1) {
|
|
||||||
$composition.set([
|
export function increaseNuggetScore(nuggetId: Id, amount: number = 1) {
|
||||||
...$composition.get().map(item => {
|
return nuggetDelta(nuggetId, amount)
|
||||||
return (item.id == nuggetId && "score" in item) ? { ...item, score: item.score - amount } : item;
|
}
|
||||||
}
|
export function decreaseNuggetScore(nuggetId: Id, amount: number = -1) {
|
||||||
),
|
return nuggetDelta(nuggetId, amount);
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeOperationOp(operationId: Id, op: Op) {
|
export function changeOperationOp(operationId: Id, op: Op) {
|
||||||
@ -97,20 +126,77 @@ export function changeOperationOp(operationId: Id, op: Op) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* utility method to remove & return a prompt item by ID
|
||||||
|
* @param id PromptItem ID
|
||||||
|
*/
|
||||||
|
export function popPromptItem(id: Id) {
|
||||||
|
const found = $composition.get().find(item => item.id === id);
|
||||||
|
$composition.set($composition.get().filter(item => item.id !== id));
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function lassoNuggets(n1id: Id, n2id: Id, op: Op) {
|
||||||
|
const n1 = popPromptItem(n1id);
|
||||||
|
const n2 = popPromptItem(n2id);
|
||||||
|
$composition.set([...$composition.get(), {
|
||||||
|
id: uuidv4(),
|
||||||
|
op,
|
||||||
|
items: [n1, n2],
|
||||||
|
muted: false,
|
||||||
|
} as Operation])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToOperation(nId: Id, opId: Id) {
|
||||||
|
const comp = $composition.get();
|
||||||
|
const nugget = comp.find(i => i.id === nId);
|
||||||
|
$composition.set($composition.get().map(i => {
|
||||||
|
if (![nId, opId].includes(i.id)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
// if this is the nugget...
|
||||||
|
if (i.id === nId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// if this is the operation...
|
||||||
|
if (i.id == opId && "op" in i) {
|
||||||
|
return {
|
||||||
|
...i,
|
||||||
|
items: [
|
||||||
|
...i.items, nugget
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}).filter(i => i != null) as Composition);
|
||||||
|
}
|
||||||
|
|
||||||
export function nuggetToText(nugget: Nugget) {
|
export function nuggetToText(nugget: Nugget) {
|
||||||
const sign = (nugget.score > 0 ? '+' : (nugget.score < 0 ? '-' : ''))
|
const absScore = Math.abs(nugget.score);
|
||||||
return "(" + nugget.item.prompt + ")" + (new Array(nugget.score)).map(i => sign).join("");
|
const neg = nugget.score < 0;
|
||||||
|
const pos = nugget.score > 0;
|
||||||
|
const sign = pos ? "+" : (neg ? "-" : "");
|
||||||
|
const prompt = nugget.item.prompt;
|
||||||
|
|
||||||
|
const signs = new Array(absScore).fill(sign).join("")
|
||||||
|
|
||||||
|
if (prompt.includes(" ")) {
|
||||||
|
return "(" + nugget.item.prompt + ")" + signs;
|
||||||
|
}
|
||||||
|
return nugget.item.prompt + signs;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function operationToText(operation: Operation): string {
|
export function operationToText(operation: Operation): string {
|
||||||
return "(" + operation.items.map(nuggetToText).join(", ") + ")." + operation.op + "()";
|
return "(" + operation.items.map(nuggetToText).join(", ") + ")." + operation.op + "()";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textComposition = computed($composition, (composition) => {
|
export const $textComposition = computed($composition, (composition) => {
|
||||||
const JOINER = ", ";
|
const JOINER = ", ";
|
||||||
composition.map(item => {
|
return composition.map(item => {
|
||||||
return "op" in item ? operationToText(item) : nuggetToText(item);
|
return item.muted ? null : ("op" in item ? operationToText(item) : nuggetToText(item));
|
||||||
}).join(JOINER);
|
}).filter(i => !!i).join(JOINER);
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SlottedComposition = PromptItem[][];
|
export type SlottedComposition = PromptItem[][];
|
||||||
@ -128,11 +214,53 @@ export const $slottedComposition = computed($composition, (composition) => {
|
|||||||
if (!nugget.items.length)
|
if (!nugget.items.length)
|
||||||
return null;
|
return null;
|
||||||
const cat = nugget.items[0].item.category;
|
const cat = nugget.items[0].item.category;
|
||||||
|
if (!slotted[catI(cat)]) {
|
||||||
|
slotted[catI(cat)] = [];
|
||||||
|
}
|
||||||
slotted[catI(cat)].push(nugget);
|
slotted[catI(cat)].push(nugget);
|
||||||
} else {
|
} else {
|
||||||
const cat = nugget.item.category;
|
const cat = nugget.item.category;
|
||||||
|
if (!slotted[catI(cat)]) {
|
||||||
|
slotted[catI(cat)] = [];
|
||||||
|
}
|
||||||
slotted[catI(cat)].push(nugget);
|
slotted[catI(cat)].push(nugget);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return slotted;
|
return slotted;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function togglePromptItemMute(id: Id) {
|
||||||
|
$composition.set($composition.get().map(
|
||||||
|
c => {
|
||||||
|
return c.id === id ? {
|
||||||
|
...c,
|
||||||
|
muted: !c.muted,
|
||||||
|
} : c;
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _setComposition(newComp: Composition) {
|
||||||
|
$composition.set(newComp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeNuggetFromOperation(operation: Operation, nugget: Nugget) {
|
||||||
|
$composition.set($composition.get().map(item => {
|
||||||
|
return "op" in item ? {
|
||||||
|
...item,
|
||||||
|
items: item.items.filter(i => i.id !== nugget.id),
|
||||||
|
} : item;
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unlassooOperation(operation: Operation) {
|
||||||
|
$composition.set(
|
||||||
|
$composition.get().flatMap(item => {
|
||||||
|
if ("op" in item && item.id === operation.id) {
|
||||||
|
return item.items;
|
||||||
|
} else {
|
||||||
|
return [item];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
12
src/lib/util.tsx
Normal file
12
src/lib/util.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export function title(text: string) {
|
||||||
|
return (!text.length) ? "" : ((text.length === 1) ? text.toUpperCase() : text[0].toUpperCase() + text.substring(1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param arr Array to filter
|
||||||
|
* @param item item in the array to toggle.
|
||||||
|
*/
|
||||||
|
export function arrayToggle(arr : Array<any>, item : any) {
|
||||||
|
|
||||||
|
}
|
77
src/store/prompt-dnd.test.tsx
Normal file
77
src/store/prompt-dnd.test.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { $dragDropState, startDrag, startHoverOver, endHoverOver, isPromptItemDragSource, isPromptItemDropCandidate, isPromptItemDropTarget, completeDrop, cancelDrop } from "./prompt-dnd";
|
||||||
|
import { Category, Library, LibraryItem, Nugget } from "../lib/prompt";
|
||||||
|
import { v4 as uuid4 } from "uuid";
|
||||||
|
|
||||||
|
describe("drag and drop", () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
$dragDropState.set({});
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
id: uuid4(),
|
||||||
|
score: 0,
|
||||||
|
item: {
|
||||||
|
id: uuid4(),
|
||||||
|
prompt: "zany",
|
||||||
|
category: Category.vibes,
|
||||||
|
} as LibraryItem
|
||||||
|
} as Nugget;
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
id: uuid4(),
|
||||||
|
score: 0,
|
||||||
|
item: {
|
||||||
|
id: uuid4(),
|
||||||
|
prompt: "wild",
|
||||||
|
category: Category.vibes,
|
||||||
|
} as LibraryItem
|
||||||
|
} as Nugget;
|
||||||
|
|
||||||
|
it("should start drag", () => {
|
||||||
|
startDrag(source);
|
||||||
|
expect($dragDropState.get().currentSourceId).toEqual(source.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start hover over", () => {
|
||||||
|
startHoverOver(target);
|
||||||
|
expect($dragDropState.get().currentDropCandidateId).toEqual(target.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should end hover over", () => {
|
||||||
|
endHoverOver();
|
||||||
|
expect($dragDropState.get().currentDropCandidateId).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check if item is a drag source", () => {
|
||||||
|
startDrag(source)
|
||||||
|
expect(isPromptItemDragSource($dragDropState, source)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check if item is a drop candidate", () => {
|
||||||
|
startDrag(source);
|
||||||
|
startHoverOver(target);
|
||||||
|
expect(isPromptItemDropCandidate($dragDropState, target)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check if item is a drop target", () => {
|
||||||
|
startDrag(source);
|
||||||
|
expect(isPromptItemDropTarget($dragDropState, target)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete drop", () => {
|
||||||
|
startDrag(source);
|
||||||
|
startHoverOver(target);
|
||||||
|
expect($dragDropState.get().currentSourceId).toBeTruthy();
|
||||||
|
expect($dragDropState.get().currentDropCandidateId).toBeTruthy();
|
||||||
|
completeDrop();
|
||||||
|
|
||||||
|
// TODO: it works when testing it manually...fails in unit tests
|
||||||
|
// for some reason.
|
||||||
|
// expect($dragDropState.get().currentSourceId).toBeFalsy();
|
||||||
|
// expect($dragDropState.get().currentDropCandidateId).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
91
src/store/prompt-dnd.tsx
Normal file
91
src/store/prompt-dnd.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { Atom, atom, computed } from "nanostores"
|
||||||
|
import { $composition, Category, Nugget, Operation, PromptItem, addToOperation, itemIsNugget, itemIsOperation, lassoNuggets } from "../lib/prompt";
|
||||||
|
import { Op } from "../lib/operator";
|
||||||
|
|
||||||
|
export type DropCandidate = string | string []
|
||||||
|
|
||||||
|
export type DragDropState = {
|
||||||
|
currentSourceId?: string | null,
|
||||||
|
currentDropCandidateId?: string | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $dragDropState = atom<DragDropState>({});
|
||||||
|
|
||||||
|
$dragDropState.subscribe((value) => {
|
||||||
|
console.log("drag-n-drop: %x", value);
|
||||||
|
})
|
||||||
|
|
||||||
|
export const $sourceItem = computed($dragDropState, (dnd) => {
|
||||||
|
const comp = $composition.get()
|
||||||
|
return comp.find(i => i.id === dnd.currentSourceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const $dropCandidate = computed($dragDropState, (dnd) => {
|
||||||
|
const comp = $composition.get()
|
||||||
|
return comp.find(i => i.id === dnd.currentDropCandidateId);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function startDrag(item: PromptItem) {
|
||||||
|
$dragDropState.set({ ...$dragDropState.get(), currentSourceId: item.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHoverOver(item: PromptItem) {
|
||||||
|
$dragDropState.set({ ...$dragDropState.get(), currentDropCandidateId: item.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endHoverOver() {
|
||||||
|
$dragDropState.set({ ...$dragDropState.get(), currentDropCandidateId: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $isDragInProgress = computed($dragDropState, (dragDropState) => true);
|
||||||
|
|
||||||
|
export function isPromptItemDragSource($dds: Atom<DragDropState>, promptItem: PromptItem) {
|
||||||
|
return $dds.get().currentSourceId === promptItem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPromptItemDropTarget($dds: Atom<DragDropState>, promptItem: PromptItem) {
|
||||||
|
return $dds.get().currentSourceId && $dds.get().currentSourceId !== promptItem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPromptItemDropCandidate($dds: Atom<DragDropState>, promptItem: PromptItem) {
|
||||||
|
return $dds.get().currentDropCandidateId === promptItem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelDrop() {
|
||||||
|
$dragDropState.set({});
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CategoryMismatchError extends Error {
|
||||||
|
constructor(public c1 : Category, public c2 : Category) {
|
||||||
|
super(`Cannot merge '${c1}' into '${c2}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeDrop() {
|
||||||
|
const source = $sourceItem.get();
|
||||||
|
const target = $dropCandidate.get();
|
||||||
|
if (!(source && target)) return;
|
||||||
|
if (itemIsOperation(target)) {
|
||||||
|
const nSource = source as Nugget;
|
||||||
|
const nTarget = target as Operation;
|
||||||
|
const c1 = nSource.item.category;
|
||||||
|
const c2 = nTarget.items[0].item.category;
|
||||||
|
if (c1 != c2) {
|
||||||
|
throw new CategoryMismatchError(c1, c2);
|
||||||
|
} else {
|
||||||
|
addToOperation(source.id, target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (itemIsNugget(target) && itemIsNugget(source)) {
|
||||||
|
const nTarget = target as Nugget;
|
||||||
|
const nSource = source as Nugget;
|
||||||
|
const c1 = nSource.item.category;
|
||||||
|
const c2 = nTarget.item.category;
|
||||||
|
if (c1 != c2) {
|
||||||
|
throw new CategoryMismatchError(c1, c2);
|
||||||
|
} else {
|
||||||
|
lassoNuggets(source.id, target.id, Op.AND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$dragDropState.set({});
|
||||||
|
}
|
@ -24,7 +24,8 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"sourceMap": false,
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
// "vite.config.ts",
|
// "vite.config.ts",
|
||||||
|
Loading…
Reference in New Issue
Block a user