Skip to content

Commit

Permalink
feat: Add text input [PT-187321121]
Browse files Browse the repository at this point in the history
This adds a textarea input where any entered text is converted to a graph.
  • Loading branch information
dougmartin committed Apr 8, 2024
1 parent fa5d36b commit af11b7f
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 38 deletions.
4 changes: 3 additions & 1 deletion src/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ export const App = () => {
const delimiterIsSpace = delimiter === " ";
const delimiterValue = delimiterIsSpace ? "" : delimiter;
const delimiterPlaceholder = delimiterIsSpace ? "(space)" : "(none)";
const sortedNodes = [...graph.nodes];
sortedNodes.sort((a, b) => a.label.localeCompare(a.label));

return (
<div className="generate">
Expand All @@ -357,7 +359,7 @@ export const App = () => {
<label>Starting State:</label>
<select onChange={handleChangeStartingState} value={startingState} disabled={disabled}>
<option value="">{AnyStartingState}</option>
{graph.nodes.map(n => <option key={n.id} value={n.id}>{n.label}</option>)}
{sortedNodes.map(n => <option key={n.id} value={n.id}>{n.label}</option>)}
</select>
</div>

Expand Down
80 changes: 56 additions & 24 deletions src/components/drawing.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { createRef, useCallback, useEffect, useRef, useState } from "react";
import { nanoid } from "nanoid";

import { DrawingMode, Graph, Point, RubberBand, Transform } from "./graph";
Expand All @@ -7,11 +7,12 @@ import { Edge, GraphData, Node } from "../type";
import { DragIcon } from "./drawing/drag-icon";
import { NodeModal } from "./drawing/node-modal";
import { Tool, Toolbar } from "./toolbar";
import { AddText } from "./drawing/add-text";

import "./drawing.scss";

const tools: Tool[] = ["select","addNode","addEdge","addText","delete","fitView","recenter","reset","home"];
const drawingTools: Tool[] = ["select", "addNode", "addEdge", "delete"];
const drawingTools: Tool[] = ["select", "addNode", "addEdge", "delete", "addText"];

interface Props {
highlightNode?: Node,
Expand All @@ -33,6 +34,9 @@ interface Props {
onDimensions?: (dimensions: {width: number, height: number}) => void;
}

const keepPunctuationRegex = /[.,?!:;]/g;
const removePunctuationRegex = /["(){}[\]_+=|\\/><]/g;

export const Drawing = (props: Props) => {
const {highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes,
graph, setGraph, setHighlightNode, setSelectedNodeId: _setSelectedNodeId,
Expand All @@ -45,6 +49,10 @@ export const Drawing = (props: Props) => {
const widthRef = useRef(0);
const heightRef = useRef(0);
const transformRef = useRef<Transform>();
const [autoArrange, setAutoArrange] = useState(false);
const [addTextWidth, setAddTextWidth] = useState(0);
const prevWordsRef = useRef<string[]>([]);
const textAreaRef = createRef<HTMLTextAreaElement>();

const setSelectedNodeId = useCallback((id?: string, skipToggle?: boolean) => {
if (drawingMode === "select") {
Expand All @@ -55,6 +63,7 @@ export const Drawing = (props: Props) => {
const handleDimensionChange = ({width, height}: {width: number, height: number}) => {
widthRef.current = width;
heightRef.current = height;
setAddTextWidth(width - 40); // for 10px margin and padding

// also tell the app so that it can translate the origin of any loaded data if needed
props.onDimensions?.({width, height});
Expand Down Expand Up @@ -103,23 +112,21 @@ export const Drawing = (props: Props) => {
return () => window.removeEventListener("keydown", listenForEscape);
}, [drawingMode, setDrawingMode, clearSelections]);

useEffect(() => {
if (drawingMode === "addText" && textAreaRef.current) {
textAreaRef.current.focus();
}
}, [drawingMode, textAreaRef]);

const handleToolSelected = (tool: Tool) => {
if (drawingTools.includes(tool)) {
setDrawingMode(tool as DrawingMode);
setAutoArrange(tool === "addText");
prevWordsRef.current = [];
clearSelections();
}
};

/*
keep for now until ok is given to delete
const handleSetSelectMode = useCallback(() => {
setDrawingMode("select");
clearSelections();
}, [setDrawingMode, clearSelections]);
*/

const addNode = useCallback(({x, y}: {x: number, y: number}) => {
setGraph(prev => {
const id = nanoid();
Expand Down Expand Up @@ -150,15 +157,13 @@ export const Drawing = (props: Props) => {
const handleClicked = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (drawingMode === "addNode") {
addNode(translateToGraphPoint(e));
// handleSetSelectMode();
} else if (drawingMode === "addEdge") {
const onSVGBackground = ((e.target as HTMLElement)?.tagName || "").toLowerCase() === "svg";
if (onSVGBackground) {
clearSelections();
// handleSetSelectMode();
}
}
}, [drawingMode, addNode/*, handleSetSelectMode */, clearSelections]);
}, [drawingMode, addNode, clearSelections]);

const handleNodeClicked = useCallback((id: string, onLoop?: boolean) => {
const node = getNode(id);
Expand All @@ -173,7 +178,6 @@ export const Drawing = (props: Props) => {
addEdge({from: firstEdgeNode.id, to: node.id});
setFirstEdgeNode(undefined);
setRubberBand(undefined);
// handleSetSelectMode();
}
}

Expand Down Expand Up @@ -202,9 +206,8 @@ export const Drawing = (props: Props) => {
edges
};
});
// handleSetSelectMode();
}
}, [addEdge, drawingMode, getNode, firstEdgeNode, setFirstEdgeNode, setGraph/*, handleSetSelectMode */]);
}, [addEdge, drawingMode, getNode, firstEdgeNode, setFirstEdgeNode, setGraph]);

const handleNodeDoubleClicked = useCallback((id: string) => {
if (drawingMode === "select") {
Expand All @@ -214,9 +217,8 @@ export const Drawing = (props: Props) => {
addEdge({from: id, to: id});
setFirstEdgeNode(undefined);
setRubberBand(undefined);
// handleSetSelectMode();
}
}, [drawingMode, addEdge/*, handleSetSelectMode */, getNode]);
}, [drawingMode, addEdge, getNode]);

const handleEdgeClicked = useCallback(({from, to}: {from: string, to: string}) => {
if (drawingMode === "delete") {
Expand All @@ -230,9 +232,8 @@ export const Drawing = (props: Props) => {
return prev;
}
});
// handleSetSelectMode();
}
}, [setGraph, drawingMode/*, handleSetSelectMode */]);
}, [setGraph, drawingMode]);

const handleDragStop = useCallback((id: string, {x, y}: Point) => {
setGraph(prev => {
Expand Down Expand Up @@ -265,6 +266,36 @@ export const Drawing = (props: Props) => {
handleClearSelectedNode();
}, [setGraph, handleClearSelectedNode]);

const handleTextChange = useCallback((newText: string) => {
const words = newText
.replace(keepPunctuationRegex, (match) => ` ${match} `)
.replace(removePunctuationRegex, " ")
.split(/\s/)
.map(w => w.trim().toLocaleLowerCase())
.filter(w => w.length > 0);

const nodes: Record<string, Node> = {};
const edges: Record<string, Edge> = {};

words.forEach((word, index) => {
nodes[word] = nodes[word] ?? {id: word, label: word, value: 0};
nodes[word].value++;

if (index > 0) {
const lastWord = words[index - 1];
const key = `${lastWord}|${word}`;
edges[key] = edges[key] ?? {from: lastWord, to: word, value: 0};
edges[key].value++;
}
});

if (JSON.stringify(words) !== JSON.stringify(prevWordsRef.current)) {
setGraph({ nodes: Object.values(nodes), edges: Object.values(edges) });
}
prevWordsRef.current = words;

}, [setGraph]);

return (
<div className="drawing">
<Toolbar
Expand All @@ -283,8 +314,8 @@ export const Drawing = (props: Props) => {
highlightEdge={highlightEdge}
highlightAllNextNodes={highlightAllNextNodes}
highlightLoopOnNode={highlightLoopOnNode}
allowDragging={drawingMode === "select" && !animating}
autoArrange={false}
allowDragging={drawingMode === "select"}
autoArrange={autoArrange}
rubberBand={rubberBand}
selectedNodeId={selectedNodeId}
animating={animating}
Expand All @@ -306,6 +337,7 @@ export const Drawing = (props: Props) => {
onChange={handleChangeNode}
onCancel={handleClearSelectedNode}
/>
<AddText ref={textAreaRef} visible={drawingMode === "addText"} width={addTextWidth} onChange={handleTextChange} />
</div>
);
};
Expand Down
20 changes: 20 additions & 0 deletions src/components/drawing/add-text.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.addText {
position: absolute;
bottom: 30px;
left: 60px;
height: 90px;
background-color: #eee;
z-index: 1;

textarea {
width: 100%;
height: 100%;
margin: 0;
padding: 10px;
}

display: none;
&.visible {
display: block;
}
}
26 changes: 26 additions & 0 deletions src/components/drawing/add-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { forwardRef } from "react";
import { clsx } from "clsx";

import "./add-text.scss";

interface Props {
visible: boolean;
width: number;
onChange: (newText: string) => void
}

// eslint-disable-next-line max-len
const placeholder = "Create a Markov chain by typing or pasting text here. WARNING: your current Markov chain will be overwritten.";

export const AddText = forwardRef<HTMLTextAreaElement, Props>(({visible, width, onChange}: Props, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.currentTarget.value.trim());
};

return (
<div className={clsx("addText", {visible})} style={{width}}>
<textarea ref={ref} placeholder={placeholder} onChange={handleChange} />
</div>
);
});
AddText.displayName = "AddText";
25 changes: 13 additions & 12 deletions src/components/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const selectedLoopArrowUrl = "url(#selectedLoopArrow)";
const unselectedArrowUrl = "url(#unselectedArrow)";
const unselectedLoopArrowUrl = "url(#unselectedLoopArrow)";

export type DrawingMode = "select"|"addNode"|"addEdge"|"delete";
export type DrawingMode = "select"|"addNode"|"addEdge"|"delete"|"addText";

export type Transform = d3.ZoomTransform;

Expand Down Expand Up @@ -344,11 +344,13 @@ export const Graph = (props: Props) => {
const {minRadius, maxRadius, minStroke, maxStroke} = settings;

graph.nodes.forEach((node, index) => {
const oldD3Node = d3Graph.nodes.find(n => n.id === node.id);

const d3Node: D3Node = {
index,
id: node.id,
x: node.x || 0,
y: node.y || 0,
x: oldD3Node?.x ?? node.x ?? 0,
y: oldD3Node?.y ?? node.y ?? 0,
label: node.label,
// radius: 15 + (5 * (node.label.length - 1)) + (5 * node.value),
radius: minRadius + ((maxRadius - minRadius) * (node.value / totalNodeValue)),
Expand Down Expand Up @@ -507,7 +509,7 @@ export const Graph = (props: Props) => {
.attr("ry", d => ry(d.radius))
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.attr("style", drawingMode !== "addNode" ? "cursor: pointer" : "")
.attr("style", drawingMode === "select" ? "cursor: pointer" : "")
.on("click", (e, d) => {
const now = Date.now();
const timeDiff = now - (lastClickTimeRef.current ?? 0);
Expand Down Expand Up @@ -569,7 +571,6 @@ export const Graph = (props: Props) => {
loops.attr("d", nodeLoopPath);
};


if (autoArrange) {
// Create a new force simulation and assign forces
simulation = d3
Expand All @@ -584,15 +585,15 @@ export const Graph = (props: Props) => {
while (simulation.alpha() > simulation.alphaMin()) {
simulation.tick();
}

// pin the nodes so that dragging does not cause a force layout change
d3Graph.nodes
.forEach((d: any) => {
d.fx = d.x;
d.fy = d.y;
});
}

// pin the nodes so that dragging does not cause a force layout change
d3Graph.nodes
.forEach((d: any) => {
d.fx = d.x;
d.fy = d.y;
});

// calculate the edge positions
d3Graph.edges = calculateEdges(d3Graph.edges);

Expand Down
2 changes: 1 addition & 1 deletion src/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import "./toolbar.scss";
export const allTools = ["select","addNode","addEdge","addText","delete","fitView","recenter","reset","home"] as const;
const toggleableTools: Tool[] = ["select","addNode","addEdge","addText","delete"];
const nonTopTools: Tool[] = ["reset","home"];
const notImplementedTools: Tool[] = ["addText"];
const notImplementedTools: Tool[] = [];

export type Tool = typeof allTools[number];

Expand Down

0 comments on commit af11b7f

Please sign in to comment.