Add support for variable links

This commit is contained in:
Ayaz Hafiz 2023-08-02 12:10:53 -05:00
parent cf71176bc5
commit a5eaba9ab3
No known key found for this signature in database
GPG key ID: 0E2A37416A25EF58
6 changed files with 271 additions and 127 deletions

View file

@ -4,6 +4,7 @@ import { QuerySubs, TypeDescriptor } from "../../engine/subs";
import { Variable } from "../../schema"; import { Variable } from "../../schema";
import DrawHeadConstructor from "../Content/HeadConstructor"; import DrawHeadConstructor from "../Content/HeadConstructor";
import { contentStyles } from "./../Content"; import { contentStyles } from "./../Content";
import { VariableName } from "./VariableName";
interface VariableElProps { interface VariableElProps {
variable: Variable; variable: Variable;
@ -51,23 +52,6 @@ function Helper({
desc: TypeDescriptor | undefined; desc: TypeDescriptor | undefined;
}): JSX.Element { }): JSX.Element {
const { bg } = contentStyles(desc); const { bg } = contentStyles(desc);
const varHeader =
!nested || raw ? (
<span
className={clsx(
"ring-1 ring-inset ring-black-100 px-1 bg-white rounded-md cursor",
nested ? "text-md" : "p-0.5"
)}
onClick={(e) => {
e.stopPropagation();
onClick?.(variable);
}}
>
{variable}
</span>
) : (
<></>
);
return ( return (
<span <span
className={clsx( className={clsx(
@ -76,7 +60,13 @@ function Helper({
nested ? "text-sm" : "p-0.5 pl-0 text-base" nested ? "text-sm" : "p-0.5 pl-0 text-base"
)} )}
> >
{varHeader} {(!nested || raw) && (
<VariableName
variable={variable}
onClick={onClick}
className={nested ? "text-md" : "p-0.5"}
/>
)}
{children ? <span className="px-1">{children}</span> : <></>} {children ? <span className="px-1">{children}</span> : <></>}
</span> </span>
); );

View file

@ -0,0 +1,34 @@
import clsx from "clsx";
import { QuerySubs } from "../../engine/subs";
import { Variable } from "../../schema";
import { VariableName } from "./VariableName";
export interface VariableLinkProps {
variable: Variable;
subs: QuerySubs;
onClick?: (variable: Variable) => void;
}
export function VariableLink({
variable,
subs,
onClick,
}: VariableLinkProps): JSX.Element {
const root = subs.get_root_key(variable);
if (variable === root) {
throw new Error("VariableLink: variable is root");
}
return (
<div className={clsx("rounded-md whitespace-nowrap space-x-1")}>
<VariableName className="inline-block" variable={variable} />
<span></span>
<VariableName
className="inline-block"
variable={root}
onClick={onClick}
/>
</div>
);
}

View file

@ -0,0 +1,30 @@
import clsx from "clsx";
import { Variable } from "../../schema";
export interface VariableNameProps {
variable: Variable;
onClick?: (variable: Variable) => void;
className?: string;
}
export function VariableName({
variable,
onClick,
className,
}: VariableNameProps): JSX.Element {
return (
<span
className={clsx(
"ring-1 ring-inset ring-black-100 px-1 bg-white rounded-md",
onClick && "cursor-pointer",
className
)}
onClick={(e) => {
e.stopPropagation();
onClick?.(variable);
}}
>
{variable}
</span>
);
}

View file

@ -64,3 +64,5 @@ export function contentStyles(desc: TypeDescriptor | undefined): ContentStyles {
return { name: "Error", bg: "bg-red-400" }; return { name: "Error", bg: "bg-red-400" };
} }
} }
export const LinkStyles: ContentStyles = { name: "Link", bg: "bg-slate-500" };

View file

@ -2,13 +2,20 @@ import clsx from "clsx";
import { Handle, Position } from "reactflow"; import { Handle, Position } from "reactflow";
import { Variable } from "../../schema"; import { Variable } from "../../schema";
import { assertExhaustive } from "../../utils/exhaustive"; import { assertExhaustive } from "../../utils/exhaustive";
import { contentStyles } from "../Content"; import { contentStyles, LinkStyles } from "../Content";
import { VariableElPretty } from "../Common/Variable"; import { VariableElPretty } from "../Common/Variable";
import { SubsSnapshot, TypeDescriptor } from "../../engine/subs"; import { SubsSnapshot, TypeDescriptor } from "../../engine/subs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TypedEmitter } from "tiny-typed-emitter"; import { TypedEmitter } from "tiny-typed-emitter";
import { VariableLink } from "../Common/VariableLink";
type AddSubVariableLink = (from: Variable, subVariable: Variable) => void; type AddSubVariableLink = ({
from,
variable,
}: {
from: Variable;
variable: Variable;
}) => void;
export interface VariableMessageEvents { export interface VariableMessageEvents {
focus: (variable: Variable) => void; focus: (variable: Variable) => void;
@ -17,7 +24,7 @@ export interface VariableMessageEvents {
export interface VariableNodeProps { export interface VariableNodeProps {
data: { data: {
subs: SubsSnapshot; subs: SubsSnapshot;
variable: Variable; rawVariable: Variable;
addSubVariableLink: AddSubVariableLink; addSubVariableLink: AddSubVariableLink;
isOutlined: boolean; isOutlined: boolean;
ee: TypedEmitter<VariableMessageEvents>; ee: TypedEmitter<VariableMessageEvents>;
@ -32,8 +39,8 @@ export default function VariableNode({
sourcePosition, sourcePosition,
}: VariableNodeProps): JSX.Element { }: VariableNodeProps): JSX.Element {
const { const {
variable,
subs, subs,
rawVariable,
addSubVariableLink, addSubVariableLink,
isOutlined: isOutlinedProp, isOutlined: isOutlinedProp,
ee: eeProp, ee: eeProp,
@ -43,10 +50,10 @@ export default function VariableNode({
useEffect(() => { useEffect(() => {
eeProp.on("focus", (focusVar: Variable) => { eeProp.on("focus", (focusVar: Variable) => {
if (focusVar !== variable) return; if (focusVar !== rawVariable) return;
setIsOutlined(true); setIsOutlined(true);
}); });
}, [eeProp, variable]); }, [eeProp, rawVariable]);
useEffect(() => { useEffect(() => {
if (!isOutlined) return; if (!isOutlined) return;
@ -59,36 +66,82 @@ export default function VariableNode({
}; };
}, [isOutlined]); }, [isOutlined]);
const desc = subs.get_root(variable); const varType = subs.get(rawVariable);
const styles = contentStyles(desc); if (!varType) throw new Error("VariableNode: no entry for variable");
const basis: BasisProps = {
subs,
origin: variable,
addSubVariableLink,
};
const content = Object.entries( let renderContent: JSX.Element;
VariableNodeContent(variable, desc, basis) let bgStyles: string;
).filter((el): el is [string, JSX.Element] => !!el[1]); const isContent = varType.type === "descriptor";
switch (varType.type) {
case "link": {
bgStyles = LinkStyles.bg;
let expandedContent = <></>; renderContent = (
if (content.length > 0) { <VariableLink
expandedContent = ( subs={subs}
<ul className="text-sm text-left mt-2 space-y-1"> variable={rawVariable}
{content.map(([key, value], i) => ( onClick={() =>
<li key={i} className="space-x-2"> addSubVariableLink({
{key}: {value} from: rawVariable,
</li> variable: subs.get_root_key(rawVariable),
))} })
</ul> }
); />
);
break;
}
case "descriptor": {
const variable = rawVariable;
const desc: TypeDescriptor = varType;
const styles = contentStyles(desc);
bgStyles = styles.bg;
const basis: BasisProps = {
subs,
origin: variable,
addSubVariableLink,
};
const content = Object.entries(
VariableNodeContent(variable, desc, basis)
).filter((el): el is [string, JSX.Element] => !!el[1]);
let expandedContent = <></>;
if (content.length > 0) {
expandedContent = (
<ul className="text-sm text-left mt-2 space-y-1">
{content.map(([key, value], i) => (
<li key={i} className="space-x-2">
{key}: {value}
</li>
))}
</ul>
);
}
renderContent = (
<>
<div>
<VariableElPretty variable={variable} subs={subs} />
</div>
{expandedContent}
</>
);
break;
}
default: {
assertExhaustive(varType);
}
} }
return ( return (
<div <div
className={clsx( className={clsx(
styles.bg, bgStyles,
"bg-opacity-50 py-2 px-4 rounded-lg border transition ease-in-out duration-700", "bg-opacity-50 rounded-lg transition ease-in-out duration-700",
isContent ? "py-2 px-4 border" : "p-0",
isOutlined && "ring-2 ring-blue-500", isOutlined && "ring-2 ring-blue-500",
"text-center font-mono" "text-center font-mono"
)} )}
@ -97,15 +150,14 @@ export default function VariableNode({
type="target" type="target"
position={targetPosition ?? Position.Top} position={targetPosition ?? Position.Top}
isConnectable={false} isConnectable={false}
style={{ background: "transparent", border: "none" }}
/> />
<div> {renderContent}
<VariableElPretty variable={variable} subs={subs} />
</div>
{expandedContent}
<Handle <Handle
type="source" type="source"
position={sourcePosition ?? Position.Bottom} position={sourcePosition ?? Position.Bottom}
isConnectable={false} isConnectable={false}
style={{ background: "transparent", border: "none" }}
/> />
</div> </div>
); );
@ -238,7 +290,7 @@ function SubVariable({
<VariableElPretty <VariableElPretty
variable={variable} variable={variable}
subs={subs} subs={subs}
onClick={() => addSubVariableLink(origin, variable)} onClick={() => addSubVariableLink({ from: origin, variable })}
/> />
); );
} }

View file

@ -18,6 +18,8 @@ import ReactFlow, {
useStore, useStore,
ReactFlowState, ReactFlowState,
Position, Position,
MarkerType,
EdgeMarkerType,
} from "reactflow"; } from "reactflow";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Variable } from "../../schema"; import { Variable } from "../../schema";
@ -154,7 +156,9 @@ async function computeLayoutedElements({
//height: 50, //height: 50,
})), })),
//@ts-ignore //@ts-ignore
edges: edges, edges: edges.map((edge) => ({
...edge,
})),
}; };
const layoutedGraph = await elk.layout(graph); const layoutedGraph = await elk.layout(graph);
@ -197,20 +201,22 @@ function newVariable(
}; };
} }
function addNodeChange(node: Node, existingNodes: Node[]): NodeChange | null { function canAddVariable(variableName: string, existingNodes: Node[]): boolean {
if (existingNodes.some((n) => n.id === node.id)) { return !existingNodes.some((n) => n.id === variableName);
return null; }
}
function canAddEdge(edgeName: string, existingEdges: Edge[]): boolean {
return !existingEdges.some((e) => e.id === edgeName);
}
function addNode(node: Node): NodeChange {
return { return {
type: "add", type: "add",
item: node, item: node,
}; };
} }
function addEdgeChange(edge: Edge, existingEdges: Edge[]): EdgeChange | null { function addEdge(edge: Edge): EdgeChange {
if (existingEdges.some((e) => e.id === edge.id)) {
return null;
}
return { return {
type: "add", type: "add",
item: edge, item: edge,
@ -322,13 +328,26 @@ function Graph({
const initialEdges: Edge[] = []; const initialEdges: Edge[] = [];
const ee = useRef(new TypedEmitter<VariableMessageEvents>()); const ee = useRef(new TypedEmitter<VariableMessageEvents>());
// Allow an unbounded number of listeners since we attach a listener for each
// variable.
ee.current.setMaxListeners(Infinity);
const [variablesNeedingFocus, setVariablesNeedingFocus] = useState<
Set<Variable>
>(new Set());
useEffect(() => {
if (variablesNeedingFocus.size === 0) {
return;
}
for (const variable of variablesNeedingFocus) {
ee.current.emit("focus", variable);
}
setVariablesNeedingFocus(new Set());
}, [variablesNeedingFocus]);
const [layoutConfig, setLayoutConfig] = const [layoutConfig, setLayoutConfig] =
useState<LayoutConfiguration>(LAYOUT_CONFIG_DOWN); useState<LayoutConfiguration>(LAYOUT_CONFIG_DOWN);
const [elements, setElements] = useState<LayoutedElements>({
nodes: initialNodes,
edges: initialEdges,
});
useAutoLayout(layoutConfig); useAutoLayout(layoutConfig);
useKeydown({ useKeydown({
@ -337,6 +356,11 @@ function Graph({
onKeydown, onKeydown,
}); });
const [elements, setElements] = useState<LayoutedElements>({
nodes: initialNodes,
edges: initialEdges,
});
const onNodesChange = useCallback((changes: NodeChange[]) => { const onNodesChange = useCallback((changes: NodeChange[]) => {
setElements(({ nodes, edges }) => { setElements(({ nodes, edges }) => {
return { return {
@ -355,81 +379,93 @@ function Graph({
}); });
}, []); }, []);
const addSubVariableLink = useCallback( interface AddNewVariableParams {
(fromN: Variable, subLinkN: Variable) => { from?: Variable;
fromN = subs.get_root_key(fromN); variable: Variable;
subLinkN = subs.get_root_key(subLinkN); }
const from = fromN.toString();
const to = subLinkN.toString(); const addNewVariable = useCallback(
({ from, variable }: AddNewVariableParams) => {
const variablesToFocus = new Set<Variable>();
setElements(({ nodes, edges }) => { setElements(({ nodes, edges }) => {
const optNewNode = addNodeChange( let fromVariable: Variable | undefined = from;
newVariable( let toVariable: Variable | undefined = variable;
to,
{
subs,
variable: subLinkN,
addSubVariableLink,
isOutlined: true,
ee: ee.current,
},
layoutConfig.isHorizontal
),
nodes
);
const newNodes = optNewNode
? applyNodeChanges([optNewNode], nodes)
: nodes;
const optNewEdge = addEdgeChange( const nodeChanges: NodeChange[] = [];
{ id: `${from}->${to}`, source: from, target: to }, const edgeChanges: EdgeChange[] = [];
edges
); while (toVariable !== undefined) {
const newEdges = optNewEdge const toVariableName = toVariable.toString();
? applyEdgeChanges([optNewEdge], edges) if (canAddVariable(toVariableName, nodes)) {
: edges; const newVariableNode = newVariable(
toVariable.toString(),
{
subs,
rawVariable: toVariable,
addSubVariableLink: addNewVariable,
isOutlined: true,
ee: ee.current,
},
layoutConfig.isHorizontal
);
nodeChanges.push(addNode(newVariableNode));
}
if (fromVariable !== undefined) {
const edgeName = `${fromVariable}->${toVariable}`;
if (canAddEdge(edgeName, edges)) {
let markerEnd: EdgeMarkerType | undefined;
if (subs.get_root_key(fromVariable) === toVariable) {
markerEnd = {
type: MarkerType.ArrowClosed,
width: 20,
height: 20,
};
}
const newEdge = addEdge({
id: `${fromVariable}->${toVariable}`,
source: fromVariable.toString(),
target: toVariableName,
markerEnd,
});
edgeChanges.push(newEdge);
}
}
variablesToFocus.add(toVariable);
fromVariable = toVariable;
const rootToVariable = subs.get_root_key(toVariable);
if (toVariable !== rootToVariable) {
toVariable = rootToVariable;
} else {
toVariable = undefined;
}
}
const newNodes = applyNodeChanges(nodeChanges, nodes);
const newEdges = applyEdgeChanges(edgeChanges, edges);
return { nodes: newNodes, edges: newEdges }; return { nodes: newNodes, edges: newEdges };
}); });
ee.current.emit("focus", subLinkN); setVariablesNeedingFocus(variablesToFocus);
}, },
[layoutConfig, subs] [layoutConfig.isHorizontal, subs]
); );
const addNode = useCallback( const addNewVariableNode = useCallback(
(variableN: Variable) => { (variable: Variable) => {
variableN = subs.get_root_key(variableN); addNewVariable({ variable });
const variable = variableN.toString();
setElements(({ nodes, edges }) => {
const optNewNode = addNodeChange(
newVariable(
variable,
{
subs,
variable: variableN,
addSubVariableLink,
isOutlined: true,
ee: ee.current,
},
layoutConfig.isHorizontal
),
nodes
);
const newNodes = optNewNode
? applyNodeChanges([optNewNode], nodes)
: nodes;
return { nodes: newNodes, edges: edges };
});
ee.current.emit("focus", variableN);
}, },
[subs, addSubVariableLink, layoutConfig] [addNewVariable]
); );
onVariable(addNode); onVariable(addNewVariableNode);
return ( return (
<ReactFlow <ReactFlow