dnd functionality works.
This commit is contained in:
parent
d9c1282d99
commit
ee266ea372
@ -24,6 +24,7 @@
|
|||||||
"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",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
|
@ -71,6 +71,9 @@ 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)
|
||||||
@ -4516,6 +4519,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}
|
||||||
@ -6806,6 +6814,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:
|
||||||
@ -11071,6 +11086,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
|
||||||
|
31
src/App.tsx
31
src/App.tsx
@ -1,9 +1,12 @@
|
|||||||
import React, { ChangeEvent } from 'react';
|
import React, { ChangeEvent, useEffect } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import PromptComposer from './components/PromptComposer';
|
import PromptComposer from './components/PromptComposer';
|
||||||
import { Box, Tabs, Tab, Typography } from '@material-ui/core';
|
import { Box, Tabs, Tab, Typography } from '@material-ui/core';
|
||||||
import { $textComposition } from './lib/prompt';
|
import { $composition, $library, $textComposition, Category, Composition, Library, LibraryItem, addItemToLibrary, insertIntoComposition, lassoNuggets } from './lib/prompt';
|
||||||
import { TextPrompt } from './components/TextPrompt';
|
import { TextPrompt } from './components/TextPrompt';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { Op } from './lib/operator';
|
||||||
|
import { v4 as uuid4 } from 'uuid';
|
||||||
|
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
@ -42,6 +45,30 @@ function a11yProps(index: number) {
|
|||||||
function App() {
|
function App() {
|
||||||
const [value, setValue] = React.useState(0);
|
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] },
|
||||||
|
{ id: uuid4(), item: libItems[1] },
|
||||||
|
], op: Op.AND,
|
||||||
|
},
|
||||||
|
{ id: uuid4(), item: libItems[2] },
|
||||||
|
] as Composition;
|
||||||
|
|
||||||
|
$library.set(libItems);
|
||||||
|
$composition.set(promptItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fillWithMockData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChange = (event: ChangeEvent<{}>, newValue: number) => {
|
const handleChange = (event: ChangeEvent<{}>, newValue: number) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
};
|
};
|
||||||
|
@ -4,7 +4,6 @@ import {NewLibraryItem} from './NewLibraryItem';
|
|||||||
import { Category, addItemToLibrary, categoryHasName } from '../lib/prompt';
|
import { Category, addItemToLibrary, categoryHasName } from '../lib/prompt';
|
||||||
|
|
||||||
jest.mock('../lib/prompt', () => ({
|
jest.mock('../lib/prompt', () => ({
|
||||||
Category: Category,
|
|
||||||
addItemToLibrary: jest.fn(),
|
addItemToLibrary: jest.fn(),
|
||||||
categoryHasName: jest.fn(),
|
categoryHasName: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
@ -2,7 +2,7 @@ import { Button, FormControl, InputLabel, MenuItem, TextField } from "@material-
|
|||||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||||
import { Category, LibraryItem, addItemToLibrary, categoryHasName } from "../lib/prompt";
|
import { Category, LibraryItem, addItemToLibrary, categoryHasName } from "../lib/prompt";
|
||||||
import { ChangeEvent, useState } from "react";
|
import { ChangeEvent, useState } from "react";
|
||||||
import {v4 as uuidv4} from "uuid"
|
import { v4 as uuidv4 } from "uuid"
|
||||||
|
|
||||||
export interface NewLibraryItemProps {
|
export interface NewLibraryItemProps {
|
||||||
onNewCreated?: () => void;
|
onNewCreated?: () => void;
|
||||||
@ -47,8 +47,8 @@ export function NewLibraryItem(props: NewLibraryItemProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<FormControl onSubmit={handleCreateItem}>
|
||||||
<FormControl>
|
<div>
|
||||||
<InputLabel htmlFor="new-prompt-category">Category</InputLabel>
|
<InputLabel htmlFor="new-prompt-category">Category</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
native
|
native
|
||||||
@ -61,21 +61,17 @@ export function NewLibraryItem(props: NewLibraryItemProps) {
|
|||||||
<option value={cat} id={cat} key={cat}>{titleCase(cat)}</option>
|
<option value={cat} id={cat} key={cat}>{titleCase(cat)}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
|
||||||
<FormControl>
|
|
||||||
{categoryHasName(category) ? (<InputLabel htmlFor="name">Name</InputLabel>) : <></>}
|
{categoryHasName(category) ? (<InputLabel htmlFor="name">Name</InputLabel>) : <></>}
|
||||||
{categoryHasName(category) ? (<TextField aria-label="Prompt Item Name" value={name} onChange={handleNameChange} id="name" />) : <></>}
|
{categoryHasName(category) ? (<TextField aria-label="Prompt Item Name" value={name} onChange={handleNameChange} id="name" />) : <></>}
|
||||||
</FormControl>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
|
||||||
<FormControl>
|
|
||||||
<InputLabel htmlFor="prompt">Prompt</InputLabel>
|
<InputLabel htmlFor="prompt">Prompt</InputLabel>
|
||||||
<TextField aria-label="Prompt Item Text" value={prompt} onChange={handlePromptChange} id="prompt" />
|
<TextField aria-label="Prompt Item Text" value={prompt} onChange={handlePromptChange} id="prompt" />
|
||||||
<Button onClick={handleCreateItem} >Create</Button>
|
<Button onClick={handleCreateItem} >Create</Button>
|
||||||
</FormControl>
|
</div>
|
||||||
</div>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1,34 +1,93 @@
|
|||||||
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 KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowUp';
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import { $composition, Nugget as NuggetType, decreaseNuggetScore, increaseNuggetScore } from '../lib/prompt';
|
||||||
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';
|
||||||
|
|
||||||
export interface NuggetProps {
|
export interface NuggetProps extends PromptItemProps {
|
||||||
nugget : NuggetType,
|
nugget: NuggetType,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Nugget(props : NuggetProps) {
|
export default function Nugget(props: NuggetProps) {
|
||||||
const {nugget} = props;
|
|
||||||
|
const { nugget,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDragEnd,
|
||||||
|
onDrop,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
} = 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 handleOnDragStart = () => {
|
||||||
|
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 = () => {
|
||||||
|
completeDrop();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='nugget'>
|
<div
|
||||||
|
className='nugget prompt-item'
|
||||||
|
id={thisId}
|
||||||
|
draggable
|
||||||
|
onDragStart={handleOnDragStart}
|
||||||
|
onDragOver={handleOnDragOver}
|
||||||
|
onDragEnd={handleOnDragEnd}
|
||||||
|
onMouseEnter={handleOnMouseEnter}
|
||||||
|
onMouseOut={handleOnMouseLeave}
|
||||||
|
data-promptitem-id={nugget.id}
|
||||||
|
>
|
||||||
<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' aria-label="incScore">
|
<Button onClick={() => increaseNuggetScore(nugget.id)} className='incScore' aria-label="incScore">
|
||||||
<KeyboardArrowUpIcon />
|
<KeyboardArrowUpIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => decreaseNuggetScore(nugget.id)} className='decScore' aria-label='decScore'>
|
<Button onClick={() => decreaseNuggetScore(nugget.id)} className='decScore' aria-label='decScore'>
|
||||||
<KeyboardArrowDownIcon />
|
<KeyboardArrowDownIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { Menu, MenuItem } from "@material-ui/core";
|
import { 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 { v4 as randomUUID } from "uuid";
|
import { v4 as randomUUID } from "uuid";
|
||||||
import { Operation as OperationType, changeOperationOp } from "../lib/prompt";
|
import { $composition, 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";
|
||||||
|
|
||||||
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, onMouseLeave } = props;
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = React.useState<{
|
const [contextMenu, setContextMenu] = React.useState<{
|
||||||
mouseX: number;
|
mouseX: number;
|
||||||
@ -19,7 +22,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 = () => {
|
||||||
|
completeDrop();
|
||||||
|
}
|
||||||
const handleContextMenu = (event: React.MouseEvent) => {
|
const handleContextMenu = (event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setContextMenu(
|
setContextMenu(
|
||||||
@ -45,7 +85,17 @@ function Operation(props : OperationProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="operation" onContextMenu={handleContextMenu}>
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={handleOnDragStart}
|
||||||
|
onDragEnd={handleOnDragEnd}
|
||||||
|
onDragOver={handleOnDragOver}
|
||||||
|
onMouseEnter={handleOnMouseEnter}
|
||||||
|
onMouseOut={handleOnMouseLeave}
|
||||||
|
className="operation"
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
data-promptitem-id={operation.id}
|
||||||
|
>
|
||||||
<div className="title">{operation.op}</div>
|
<div className="title">{operation.op}</div>
|
||||||
<div className="nuggets">
|
<div className="nuggets">
|
||||||
{
|
{
|
||||||
|
@ -3,13 +3,15 @@ import Masonry from '@mui/lab/Masonry';
|
|||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import "./PromptComposer.css";
|
import "./PromptComposer.css";
|
||||||
import { PromptLibrary } from './PromptLibrary';
|
import { PromptLibrary } from './PromptLibrary';
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { $slottedComposition, LibraryItem, PromptItem, insertIntoComposition } from '../lib/prompt';
|
import { $slottedComposition, LibraryItem, PromptItem, addToOperation, insertIntoComposition, itemIsNugget, itemIsOperation, lassoNuggets } from '../lib/prompt';
|
||||||
import { Category } from '@mui/icons-material';
|
import { Category } from '@mui/icons-material';
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import Nugget from './Nugget';
|
import Nugget from './Nugget';
|
||||||
import { Stack } from '@mui/material';
|
import { Stack } from '@mui/material';
|
||||||
import { Operation } from './Operation';
|
import { Op, Operation } from './Operation';
|
||||||
|
import { PromptItemProps } from './PromptItem';
|
||||||
|
import { $dragDropState, $dropCandidate, $isDragInProgress, $sourceItem, completeDrop, endHoverOver, startDrag, startHoverOver } from '../store/prompt-dnd';
|
||||||
|
|
||||||
export interface PromptComposerProps {
|
export interface PromptComposerProps {
|
||||||
|
|
||||||
@ -32,8 +34,45 @@ export default function PromptComposer(props: PromptComposerProps) {
|
|||||||
|
|
||||||
const slottedComposition = useStore($slottedComposition);
|
const slottedComposition = useStore($slottedComposition);
|
||||||
|
|
||||||
const promptItemFactory = (promptItem : PromptItem, key : string) => {
|
const promptItemFactory = (promptItem: PromptItem, key: string) => {
|
||||||
return "op" in promptItem ? <Operation operation={promptItem} key={key} /> : <Nugget nugget={promptItem} key={key} />
|
|
||||||
|
const callbacks = {
|
||||||
|
onDragStart: (item: PromptItem) => {
|
||||||
|
if (itemIsNugget(promptItem)) {
|
||||||
|
startDrag(item);
|
||||||
|
}
|
||||||
|
// TODO: operation
|
||||||
|
},
|
||||||
|
onDrop: (item: PromptItem) => {
|
||||||
|
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: (item: PromptItem) => {
|
||||||
|
|
||||||
|
},
|
||||||
|
onMouseEnter: (item: PromptItem) => {
|
||||||
|
},
|
||||||
|
onMouseLeave: (item: PromptItem) => {
|
||||||
|
endHoverOver();
|
||||||
|
},
|
||||||
|
} as PromptItemProps;
|
||||||
|
|
||||||
|
return ("op" in promptItem ?
|
||||||
|
<Operation operation={promptItem} key={key} {...callbacks} />
|
||||||
|
: <Nugget nugget={promptItem} key={key} {...callbacks} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -51,10 +90,10 @@ export default function PromptComposer(props: PromptComposerProps) {
|
|||||||
<div>
|
<div>
|
||||||
{
|
{
|
||||||
slottedComposition.map((itemCol, i) => (
|
slottedComposition.map((itemCol, i) => (
|
||||||
<Stack>
|
<Stack>
|
||||||
{itemCol.map((promptItem, j) => promptItemFactory(promptItem, `item-${j}-${j}`))}
|
{itemCol.map((promptItem, j) => promptItemFactory(promptItem, `item-${j}-${j}`))}
|
||||||
</Stack>
|
</Stack>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
7
src/components/PromptItem.css
Normal file
7
src/components/PromptItem.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.prompt-item .drag-target-highlight {
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item .drag-target-hover {
|
||||||
|
border: 1px solid blue;
|
||||||
|
}
|
22
src/components/PromptItem.tsx
Normal file
22
src/components/PromptItem.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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?: (item: PIType) => void,
|
||||||
|
onDragOver?: (item: PIType) => void,
|
||||||
|
onDrop?: (item : PIType) => void,
|
||||||
|
onMouseEnter?: (item : PIType) => void,
|
||||||
|
onMouseLeave?: (item : PIType) => void,
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { v4 as randomUUID } from "uuid";
|
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";
|
||||||
|
|
||||||
@ -47,6 +47,14 @@ export type Operation = IdAble & {
|
|||||||
|
|
||||||
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>([])
|
||||||
@ -103,6 +111,51 @@ 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],
|
||||||
|
} 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 absScore = Math.abs(nugget.score);
|
const absScore = Math.abs(nugget.score);
|
||||||
const neg = nugget.score < 0;
|
const neg = nugget.score < 0;
|
||||||
|
73
src/store/prompt-dnd.test.tsx
Normal file
73
src/store/prompt-dnd.test.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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 } 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);
|
||||||
|
endHoverOver();
|
||||||
|
completeDrop();
|
||||||
|
expect($dragDropState.get().currentSourceId).toBeFalsy();
|
||||||
|
expect($dragDropState.get().currentDropCandidateId).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
83
src/store/prompt-dnd.tsx
Normal file
83
src/store/prompt-dnd.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { Atom, atom, computed } from "nanostores"
|
||||||
|
import { $composition, Nugget, Operation, PromptItem, addToOperation, itemIsNugget, itemIsOperation, lassoNuggets } from "../lib/prompt";
|
||||||
|
import { Op } from "../lib/operator";
|
||||||
|
|
||||||
|
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 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) {
|
||||||
|
console.error("Category mismatch: cannot drop a %s into %s", 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) {
|
||||||
|
console.error("Category mismatch: cannot drop a %s into %s", c1, c2);
|
||||||
|
} else {
|
||||||
|
lassoNuggets(source.id, target.id, Op.AND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancelDrop();
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user