219 lines
7.0 KiB
TypeScript
219 lines
7.0 KiB
TypeScript
import {
|
|
Handle,
|
|
type NodeProps,
|
|
Position,
|
|
type Connection,
|
|
type Edge,
|
|
type Node,
|
|
} from '@xyflow/react';
|
|
import { Toolbar } from '../components/NodeComponents';
|
|
import styles from '../../VisProg.module.css';
|
|
import useFlowStore from '../VisProgStores';
|
|
import { useState } from 'react';
|
|
import { RealtimeTextField, TextField } from '../../../../components/TextField';
|
|
import duplicateIndices from '../../../../utils/duplicateIndices';
|
|
|
|
/**
|
|
* The default data structure for a Trigger node
|
|
*
|
|
* Represents configuration for a node that activates when a specific condition is met,
|
|
* such as keywords being spoken or emotions detected.
|
|
*
|
|
* @property label: the display label of this Trigger node.
|
|
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
|
* @property triggerType - The type of trigger ("keywords" or a custom string).
|
|
* @property triggers - The list of keyword triggers (if applicable).
|
|
* @property hasReduce - Whether this node supports reduction logic.
|
|
*/
|
|
export type TriggerNodeData = {
|
|
label: string;
|
|
droppable: boolean;
|
|
triggerType: "keywords" | string;
|
|
triggers: Keyword[] | never;
|
|
hasReduce: boolean;
|
|
};
|
|
|
|
|
|
export type TriggerNode = Node<TriggerNodeData>
|
|
|
|
|
|
/**
|
|
* Determines whether a Trigger node can connect to another node or edge.
|
|
*
|
|
* @param connection - The connection or edge being attempted to connect towards.
|
|
* @returns `true` if the connection is defined; otherwise, `false`.
|
|
*/
|
|
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
|
return (connection != undefined);
|
|
}
|
|
|
|
/**
|
|
* Defines how a Trigger node should be rendered
|
|
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
|
* @returns The rendered TriggerNode React element (React.JSX.Element).
|
|
*/
|
|
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|
const data = props.data;
|
|
const {updateNodeData} = useFlowStore();
|
|
|
|
const setKeywords = (keywords: Keyword[]) => {
|
|
updateNodeData(props.id, {...data, triggers: keywords});
|
|
}
|
|
|
|
return <>
|
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
|
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
|
{data.triggerType === "emotion" && (
|
|
<div className={"flex-row gap-md"}>Emotion?</div>
|
|
)}
|
|
{data.triggerType === "keywords" && (
|
|
<Keywords
|
|
keywords={data.triggers}
|
|
setKeywords={setKeywords}
|
|
/>
|
|
)}
|
|
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
|
</div>
|
|
</>;
|
|
}
|
|
|
|
/**
|
|
* Reduces each Trigger, including its children down into its core data.
|
|
* @param node - The Trigger node to reduce.
|
|
* @param nodes - The list of all nodes in the current flow graph.
|
|
* @returns A simplified object containing the node label and its list of triggers.
|
|
*/
|
|
export function TriggerReduce(node: Node, nodes: Node[]) {
|
|
// Replace this for nodes functionality
|
|
if (nodes.length <= -1) {
|
|
console.warn("Impossible nodes length in TriggerReduce")
|
|
}
|
|
const data = node.data as TriggerNodeData;
|
|
return {
|
|
label: data.label,
|
|
list: data.triggers,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles logic that occurs when a connection is made involving a Trigger node.
|
|
*
|
|
* @param thisNode - The current Trigger node being connected.
|
|
* @param otherNode - The other node involved in the connection.
|
|
* @param isThisSource - Whether this node was the source of the connection.
|
|
*/
|
|
export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
|
// Replace this for connection logic
|
|
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
|
console.warn("Impossible node connection called in EndConnects")
|
|
}
|
|
}
|
|
|
|
// Definitions for the possible triggers, being keywords and emotions
|
|
|
|
/** Represents a single keyword trigger entry. */
|
|
type Keyword = { id: string, keyword: string };
|
|
|
|
/** Properties for an emotion-type trigger node. */
|
|
export type EmotionTriggerNodeProps = {
|
|
type: "emotion";
|
|
value: string;
|
|
}
|
|
|
|
/** Props for a keyword-type trigger node. */
|
|
export type KeywordTriggerNodeProps = {
|
|
type: "keywords";
|
|
value: Keyword[];
|
|
}
|
|
|
|
/** Union type for all possible Trigger node configurations. */
|
|
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
|
|
|
|
|
/**
|
|
* Renders an input element that allows users to add new keyword triggers.
|
|
*
|
|
* When the input is committed, the `addKeyword` callback is called with the new keyword.
|
|
*
|
|
* @param param0 - An object containing the `addKeyword` function.
|
|
* @returns A React element(React.JSX.Element) providing an input for adding keywords.
|
|
*/
|
|
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
|
const [input, setInput] = useState("");
|
|
|
|
const text_input_id = "keyword_adder_input";
|
|
|
|
return <div className={"flex-row gap-md"}>
|
|
<label htmlFor={text_input_id}>New Keyword:</label>
|
|
<RealtimeTextField
|
|
id={text_input_id}
|
|
value={input}
|
|
setValue={setInput}
|
|
onCommit={() => {
|
|
if (!input) return;
|
|
addKeyword(input);
|
|
setInput("");
|
|
}}
|
|
placeholder={"..."}
|
|
className={"flex-1"}
|
|
/>
|
|
</div>;
|
|
}
|
|
|
|
/**
|
|
* Displays and manages a list of keyword triggers for a Trigger node.
|
|
* Handles adding, editing, and removing keywords, as well as detecting duplicate entries.
|
|
*
|
|
* @param keywords - The current list of keyword triggers.
|
|
* @param setKeywords - A callback to update the keyword list in the parent node.
|
|
* @returns A React element(React.JSX.Element) for editing keyword triggers.
|
|
*/
|
|
function Keywords({
|
|
keywords,
|
|
setKeywords,
|
|
}: {
|
|
keywords: Keyword[];
|
|
setKeywords: (keywords: Keyword[]) => void;
|
|
}) {
|
|
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
|
|
|
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
|
|
|
/** Indices of duplicates in the keyword array. */
|
|
const [duplicates, setDuplicates] = useState<number[]>([]);
|
|
|
|
function replace(id: string, value: string) {
|
|
value = value.trim();
|
|
const newKeywords = value === ""
|
|
? keywords.filter((kw) => kw.id != id)
|
|
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
|
setKeywords(newKeywords);
|
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
|
}
|
|
|
|
function add(value: string) {
|
|
value = value.trim();
|
|
if (value === "") return;
|
|
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
|
setKeywords(newKeywords);
|
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
|
}
|
|
|
|
return <>
|
|
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
|
{[...keywords].map(({id, keyword}, index) => {
|
|
return <div key={id} className={"flex-row gap-md"}>
|
|
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
|
<TextField
|
|
id={inputElementId(id)}
|
|
value={keyword}
|
|
setValue={(val) => replace(id, val)}
|
|
placeholder={"..."}
|
|
className={"flex-1"}
|
|
invalid={duplicates.includes(index)}
|
|
/>
|
|
</div>;
|
|
})}
|
|
<KeywordAdder addKeyword={add} />
|
|
</>;
|
|
} |