diff --git a/package.json b/package.json index cc6f730..e810831 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "nanostores": "^0.10.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-redux": "^9.1.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba07f93..f155690 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ dependencies: react-dom: specifier: ^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: specifier: ^9.1.0 version: 9.1.0(@types/react@18.2.57)(react@18.2.0)(redux@5.0.1) @@ -4516,6 +4519,11 @@ packages: hasBin: 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): resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==} engines: {node: ^10 || ^12 || >=14} @@ -6806,6 +6814,13 @@ packages: webpack: 5.90.3 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: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} dependencies: @@ -11071,6 +11086,18 @@ packages: scheduler: 0.23.0 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: resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==} dev: false diff --git a/src/App.tsx b/src/App.tsx index bf01b0e..3fa48b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,12 @@ -import React, { ChangeEvent } from 'react'; +import React, { ChangeEvent, useEffect } from 'react'; import './App.css'; import PromptComposer from './components/PromptComposer'; 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 { useStore } from '@nanostores/react'; +import { Op } from './lib/operator'; +import { v4 as uuid4 } from 'uuid'; interface TabPanelProps { @@ -42,6 +45,30 @@ function a11yProps(index: number) { 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] }, + { 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) => { setValue(newValue); }; diff --git a/src/components/NewLibraryItem.test.tsx b/src/components/NewLibraryItem.test.tsx index eb18e41..52f304c 100644 --- a/src/components/NewLibraryItem.test.tsx +++ b/src/components/NewLibraryItem.test.tsx @@ -4,7 +4,6 @@ import {NewLibraryItem} from './NewLibraryItem'; import { Category, addItemToLibrary, categoryHasName } from '../lib/prompt'; jest.mock('../lib/prompt', () => ({ - Category: Category, addItemToLibrary: jest.fn(), categoryHasName: jest.fn(), })); diff --git a/src/components/NewLibraryItem.tsx b/src/components/NewLibraryItem.tsx index 0b68081..ef32922 100644 --- a/src/components/NewLibraryItem.tsx +++ b/src/components/NewLibraryItem.tsx @@ -2,7 +2,7 @@ import { Button, FormControl, InputLabel, MenuItem, TextField } from "@material- 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 { v4 as uuidv4 } from "uuid" export interface NewLibraryItemProps { onNewCreated?: () => void; @@ -47,8 +47,8 @@ export function NewLibraryItem(props: NewLibraryItemProps) { return (
-
- + +
Category - -
-
- +
+
{categoryHasName(category) ? (Name) : <>} {categoryHasName(category) ? () : <>} - -
-
- +
+
Prompt - -
+
+
) } \ No newline at end of file diff --git a/src/components/Nugget.tsx b/src/components/Nugget.tsx index f9221b4..db5b939 100644 --- a/src/components/Nugget.tsx +++ b/src/components/Nugget.tsx @@ -1,34 +1,93 @@ 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 KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowUp'; - +import { $composition, Nugget as NuggetType, decreaseNuggetScore, increaseNuggetScore } from '../lib/prompt'; 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 { - nugget : NuggetType, +export interface NuggetProps extends PromptItemProps { + nugget: NuggetType, } -export default function Nugget(props : NuggetProps) { - const {nugget} = props; +export default function Nugget(props: NuggetProps) { + + const { nugget, + onDragStart, + onDragOver, + onDragEnd, + onDrop, + onMouseEnter, + onMouseLeave, + } = props; 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 ( -
+
{nugget.item.name || nugget.item.prompt} {scoreDisp} - - - - + + + +
); diff --git a/src/components/Operation.tsx b/src/components/Operation.tsx index 9b91081..3e14995 100644 --- a/src/components/Operation.tsx +++ b/src/components/Operation.tsx @@ -1,17 +1,20 @@ 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 { Op } from "../lib/operator"; 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 { PromptItemProps } from "./PromptItem"; +import { useStore } from "@nanostores/react"; +import { $sourceItem, cancelDrop, completeDrop, startHoverOver } from "../store/prompt-dnd"; -interface OperationProps { - operation : OperationType +interface OperationProps extends PromptItemProps { + operation: OperationType } -function Operation(props : OperationProps) { - const {operation} = props; +function Operation(props: OperationProps) { + const { operation, onDragStart, onDragOver, onDragEnd, onDrop, onMouseEnter, onMouseLeave } = props; const [contextMenu, setContextMenu] = React.useState<{ mouseX: number; @@ -19,7 +22,44 @@ function Operation(props : OperationProps) { } | null>(null); 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) => { event.preventDefault(); setContextMenu( @@ -45,7 +85,17 @@ function Operation(props : OperationProps) { } return ( -
+
{operation.op}
{ diff --git a/src/components/PromptComposer.tsx b/src/components/PromptComposer.tsx index a52cd28..a49384e 100644 --- a/src/components/PromptComposer.tsx +++ b/src/components/PromptComposer.tsx @@ -3,13 +3,15 @@ import Masonry from '@mui/lab/Masonry'; import AddIcon from '@mui/icons-material/Add'; import "./PromptComposer.css"; import { PromptLibrary } from './PromptLibrary'; -import React, { useState } from 'react'; -import { $slottedComposition, LibraryItem, PromptItem, insertIntoComposition } from '../lib/prompt'; +import React, { useEffect, useState } from 'react'; +import { $slottedComposition, LibraryItem, PromptItem, addToOperation, insertIntoComposition, itemIsNugget, itemIsOperation, lassoNuggets } from '../lib/prompt'; import { Category } from '@mui/icons-material'; import { useStore } from '@nanostores/react' import Nugget from './Nugget'; 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 { @@ -32,8 +34,45 @@ export default function PromptComposer(props: PromptComposerProps) { const slottedComposition = useStore($slottedComposition); - const promptItemFactory = (promptItem : PromptItem, key : string) => { - return "op" in promptItem ? : + const promptItemFactory = (promptItem: PromptItem, key: string) => { + + 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 ? + + : ) } return ( @@ -51,10 +90,10 @@ export default function PromptComposer(props: PromptComposerProps) {
{ slottedComposition.map((itemCol, i) => ( - - {itemCol.map((promptItem, j) => promptItemFactory(promptItem, `item-${j}-${j}`))} - - )) + + {itemCol.map((promptItem, j) => promptItemFactory(promptItem, `item-${j}-${j}`))} + + )) }
diff --git a/src/components/PromptItem.css b/src/components/PromptItem.css new file mode 100644 index 0000000..80993f6 --- /dev/null +++ b/src/components/PromptItem.css @@ -0,0 +1,7 @@ +.prompt-item .drag-target-highlight { + border: 1px solid red; +} + +.prompt-item .drag-target-hover { + border: 1px solid blue; +} \ No newline at end of file diff --git a/src/components/PromptItem.tsx b/src/components/PromptItem.tsx new file mode 100644 index 0000000..15f5bad --- /dev/null +++ b/src/components/PromptItem.tsx @@ -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, +} \ No newline at end of file diff --git a/src/lib/prompt.tsx b/src/lib/prompt.tsx index 0500e46..1c80448 100644 --- a/src/lib/prompt.tsx +++ b/src/lib/prompt.tsx @@ -1,4 +1,4 @@ -import { v4 as randomUUID } from "uuid"; +import { v4 as randomUUID, v4 as uuidv4 } from "uuid"; import { Op } from "./operator"; import { atom, computed } from "nanostores"; @@ -47,6 +47,14 @@ export type Operation = IdAble & { 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; export const $library = atom([]) @@ -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) { const absScore = Math.abs(nugget.score); const neg = nugget.score < 0; diff --git a/src/store/prompt-dnd.test.tsx b/src/store/prompt-dnd.test.tsx new file mode 100644 index 0000000..48aa644 --- /dev/null +++ b/src/store/prompt-dnd.test.tsx @@ -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(); + }); +}); diff --git a/src/store/prompt-dnd.tsx b/src/store/prompt-dnd.tsx new file mode 100644 index 0000000..cbdb8ea --- /dev/null +++ b/src/store/prompt-dnd.tsx @@ -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.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, promptItem: PromptItem) { + return $dds.get().currentSourceId === promptItem.id; +} + +export function isPromptItemDropTarget($dds: Atom, promptItem: PromptItem) { + return $dds.get().currentSourceId && $dds.get().currentSourceId !== promptItem.id; +} + +export function isPromptItemDropCandidate($dds: Atom, 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(); +}