Select a node and click the Add button to create an annotation.
Each annotation has an Edit button to edit the text or delete the annotation.
This story uses a HTML overlay to allow creation and editing of the annotation text, and uses labels to create interactive elements on the annotations themselves.
See also
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { Chart } from "regraph";
import { colors, createAnnotation, icons, isNode, items } from "./data";
const editLabelIndex = 2;
const layout = {
name: "sequential",
level: "level",
tightness: 0,
stretch: 0.54,
animate: false,
};
const defaultOverlayState = {
annotationPreviewText: null,
annotationPreviewPosition: null,
annotationPreviewPositionSynced: false,
annotateButtonPosition: null,
};
function AnnotateButton({ annotate, position }) {
return (
<button
type="button"
className="button"
id="annotate-button"
onClick={annotate}
style={{
top: position.top,
left: position.left,
}}
aria-label="Annotate"
>
<i>
<img src="/img/annotate.svg" alt="Annotate" />
</i>
</button>
);
}
function AnnotationPreview({
position,
text,
actions: { updateAnnotationTextAndHeight, finalizeAnnotation },
}) {
const [previewText, setPreviewText] = useState(text ?? "");
const textMeasurementSpanRef = useRef(null);
const textAreaRef = useRef(null);
const previewDivRef = useRef(null);
const onAnnotationInput = (e) => {
const textInput = e.target.value;
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
setPreviewText(textInput);
};
const onKeyDown = ({ key, nativeEvent }) => {
if (key === "Enter") {
// prevent new lines being added to our editor
nativeEvent.preventDefault();
}
};
const onKeyUp = ({ key }) => {
if (key === "Enter" || key === "Escape") {
finalizeAnnotation("dismiss");
}
};
const formatAnnotationInputText = (str) => {
let formattedText = "";
const words = str.split(" ");
words.forEach((word) => {
textMeasurementSpanRef.current.textContent = word;
/* Use the hidden measurement span to check if a word doesn't fit on a single line
in the text area if it does wrap it across multiple lines */
if (
textMeasurementSpanRef.current.getBoundingClientRect().width >
textAreaRef.current.clientWidth
) {
formattedText += `${wrapLongWord(word)} `;
} else {
formattedText += `${word} `;
}
});
return formattedText.trim();
};
/* ReGraph doesn't wrap strings without spaces so add new lines manually
to handle long words which exceed the annotation body width */
const wrapLongWord = (word) => {
let wrappedText = "";
let tempLine = "";
[...word].forEach((char) => {
// Add characters to the measurement span incrementally to check if the word exceeds the annotation bounds
textMeasurementSpanRef.current.textContent = `${tempLine}${char}`;
if (
textMeasurementSpanRef.current.getBoundingClientRect().width >
textAreaRef.current.clientWidth
) {
wrappedText += `${tempLine}\n`;
tempLine = char;
} else {
tempLine += char;
}
});
// Add remaining part of word
wrappedText += tempLine;
return wrappedText;
};
useEffect(() => {
if (textAreaRef.current) {
// focus the cursor at the end of the existing annotation text
const length = textAreaRef.current.value.length;
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(length, length);
// size the annotation preview to fit the existing text
textAreaRef.current.style.height = "auto";
textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`;
}
}, []);
/* update the actual annotation on text area input so we can
position the preview correctly as the annotation changes size */
useEffect(() => {
if (previewDivRef.current) {
const cleanTextInput = previewText.replace(/\n/g, "").trim();
// Format text so long words are wrapped correctly before updating chart annotation text
const formattedText = formatAnnotationInputText(cleanTextInput);
updateAnnotationTextAndHeight(formattedText, previewDivRef.current.offsetHeight);
}
}, [previewText]);
return (
<>
<div
id="annotation-preview"
style={{ top: position?.top, left: position?.left }}
ref={previewDivRef}
>
<div id="annotation-upper">
<textarea
placeholder="Add comment"
defaultValue={text ?? ""}
id="annotation-text"
rows={1}
onInput={onAnnotationInput}
ref={textAreaRef}
spellCheck={false}
wrap="hard"
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
/>
{text && (
<button
type="button"
className="button"
id="ghost-done-button"
aria-label="Placeholder"
disabled
>
<i className="material-icons">{icons.done}</i>
</button>
)}
</div>
{previewText && (
<div id="annotation-toolbar">
<div id="edit-controls">
<button
type="button"
className="button"
id="delete-button"
aria-label="Delete"
onClick={() => finalizeAnnotation("delete")}
onContextMenu={(e) => e.preventDefault()}
>
<i className="material-icons">{icons.delete}</i>
</button>
<button
type="button"
className="button"
id="done-button"
aria-label="Done"
onClick={() => finalizeAnnotation("dismiss")}
onContextMenu={(e) => e.preventDefault()}
>
<i className="material-icons">{icons.done}</i>
</button>
</div>
</div>
)}
</div>
<span id="text-measure" ref={textMeasurementSpanRef} />
</>
);
}
function UserCreatedAnnotations() {
const [state, setState] = useState({
items: items,
selectedNodeIds: [],
annotateButtonPosition: null,
annotationBeingUpdatedId: null,
animation: {
animate: true,
time: 300,
},
selection: undefined,
});
const [overlayState, setOverlayState] = useState(defaultOverlayState);
const [chartClasses, setChartClasses] = useState([]);
const chartContainerRef = useRef(null);
const chartRef = useRef(null);
const hasLoaded = useRef(false);
const annotationCount = useRef(1);
const zoom = useRef();
const canvasElRef = useRef(null);
useEffect(() => {
// get the canvas element (ReGraph doesn't expose the canvas element so we can't forward the ref)
const canvasEl = document.querySelector("canvas");
if (canvasEl) {
canvasElRef.current = canvasEl;
}
}, []);
useEffect(() => {
const handleResize = () => finalizeAnnotation();
window.addEventListener("resize", handleResize);
// clean up the resize event listener
return () => {
window.removeEventListener("resize", handleResize);
};
}, [state.annotationBeingUpdatedId]);
// Update the position of the annotate button upon node selection cahnge
useEffect(() => {
updateAnnotateButtonPosition();
}, [state.selectedNodeIds]);
// When we clear selection manually give selection
// control back to ReGraph by setting the prop to undefined
useEffect(() => {
setState((current) => ({
...current,
selection: undefined,
}));
}, [state.selection]);
const updateAnnotateButtonPosition = () => {
const selectedNodeCount = state.selectedNodeIds.length;
let annotateButtonPosition = null;
// Hide the annotate button when no nodes are selected
if (selectedNodeCount > 0) {
const mostRecentlySelectedNodeId = state.selectedNodeIds[state.selectedNodeIds.length - 1];
const nodeWithAnnotateButton = state.items[mostRecentlySelectedNodeId];
const nodeSizeMultiplier = nodeWithAnnotateButton.size || 1;
const nodeSize = 24 * nodeSizeMultiplier * zoom.current;
const itemViewCoords = chartRef.current?.getViewCoordinatesOfItem(mostRecentlySelectedNodeId);
// Hide the annotate button when the attached node isn't in view
annotateButtonPosition =
itemViewCoords !== undefined
? {
top: `${itemViewCoords.y - nodeSize - 27}px`,
left: `${itemViewCoords.x + nodeSize}px`,
}
: null;
}
setOverlayState((current) => {
return {
...current,
annotateButtonPosition,
};
});
};
const annotate = () => {
const subject = state.selectedNodeIds;
const annotationId = `annotation-${++annotationCount.current}`;
const annotation = createAnnotation(subject, "", {
width: 274,
height: 46,
});
// Hide the overlay button
setOverlayState((current) => ({
...current,
annotateButtonPosition: null,
}));
// Add the new annotation to the chart
setState((current) => ({
...current,
annotationBeingUpdatedId: annotationId,
items: {
...current.items,
[annotationId]: annotation,
},
selection: [],
}));
};
const getAnnotationBeingUpdated = () => {
if (state.annotationBeingUpdatedId) {
return state.items[state.annotationBeingUpdatedId];
}
return null;
};
const updateAnnotationTextAndHeight = (text, height) => {
const annotationBeingUpdated = getAnnotationBeingUpdated();
if (annotationBeingUpdated === null) return;
const labels = [...annotationBeingUpdated.label];
// Update the content label's text
labels[1] = {
...labels[1],
text,
};
setState((current) => ({
...current,
items: {
...current.items,
[state.annotationBeingUpdatedId]: {
...annotationBeingUpdated,
shape: {
...annotationBeingUpdated.shape,
height,
},
label: labels,
},
},
}));
setOverlayState((current) => ({
...current,
annotationPreviewText: text,
// re-place the preview overlay when we update the text / height
annotationPreviewPositionSynced: false,
}));
};
const finalizeAnnotation = (action) => {
const annotation = getAnnotationBeingUpdated();
if (annotation === null) return;
const updatedItems = {
...state.items,
};
if (action === "delete" || !overlayState.annotationPreviewText) {
delete updatedItems[state.annotationBeingUpdatedId];
}
setState((current) => ({
...current,
annotationBeingUpdatedId: null,
items: updatedItems,
selectedNodeIds: [],
selection: [],
}));
setTimeout(() => setOverlayState(defaultOverlayState), 1);
};
const editAnnotation = (annotationId) => {
const annotation = state.items[annotationId];
// Get the details of the annotation we are editing so we can pre-fill the overlay text / container
const { label } = annotation;
setState((current) => ({
...current,
annotationBeingUpdatedId: annotationId,
selection: [],
}));
setOverlayState((current) => ({
...current,
annotationPreviewText: label[1].text,
// hide the annotate button if we are showing it
annotateButtonPosition: null,
}));
};
const isAnnotateButtonVisible = () =>
state.selectedNodeIds.length > 0 && state.annotationBeingUpdatedId === null;
const onClickHandler = (e) => {
if (state.annotationBeingUpdatedId !== null) {
e.preventDefault();
finalizeAnnotation();
}
// Edit annotation
else if (e.id && e.itemType === "annotation" && e.subItem?.index === editLabelIndex) {
editAnnotation(e.id);
}
};
const onDragHandler = () => {
if (isAnnotateButtonVisible()) {
updateAnnotateButtonPosition();
} else if (state.annotationBeingUpdatedId !== null) {
finalizeAnnotation();
}
};
const onViewChangeHandler = (viewChange) => {
if (state.annotationBeingUpdatedId !== null) {
finalizeAnnotation();
}
if (isAnnotateButtonVisible()) {
setTimeout(updateAnnotateButtonPosition, 1);
}
if ("zoom" in viewChange && viewChange.zoom !== zoom.current) {
if (zoom.current) {
zoom.current = viewChange.zoom;
}
if (state.annotationBeingUpdatedId !== null) {
setOverlayState((current) => ({
...current,
annotationPreviewPositionSynced: false,
}));
}
}
};
const onItemInteractionHandler = ({ id, itemType, selected, hovered, subItem, setStyle }) => {
if (itemType === "node" && selected) {
setStyle({
[id]: {
color: colors.selectedNode,
},
});
}
const hoveringEditAnnotationLabel =
itemType === "annotation" && hovered && subItem?.index === 2;
if (hoveringEditAnnotationLabel) {
setStyle({
[id]: {
label: [{}, {}, { backgroundColor: colors.hoveredEditLabel }],
},
});
}
};
const onHoverHandler = ({ id, subItem }) => {
const hoveringEditAnnotationLabel = id && subItem?.index === editLabelIndex;
setChartClasses((current) =>
hoveringEditAnnotationLabel
? [...current, "cursor-pointer"]
: current.filter((className) => !className.startsWith("cursor-"))
);
};
const onChangeHandler = ({ selection, view }) => {
if (selection) {
// Only nodes can be selected as subjects for annotations in this story
const selectedNodeIds = Object.entries(selection)
.filter(([, item]) => isNode(item))
.map(([id]) => id);
setState((current) => ({
...current,
selectedNodeIds,
}));
}
if (view) {
// Update the zoom level so we can position the annotate button appropriately
zoom.current = view.zoom;
}
if (!hasLoaded.current) {
setState((current) => ({
...current,
animation: {
...current.animation,
animate: false,
},
}));
}
};
const isAnnotationOutOfChartBounds = (annotationPosition) => {
if (chartContainerRef.current === null) return null;
const chartContainerBounds = chartContainerRef.current.getBoundingClientRect();
const outOfVerticalBounds =
annotationPosition.y1 <= 0 || annotationPosition.y2 >= chartContainerBounds.height;
const outOfHorizontalBounds =
annotationPosition.x1 <= 0 || annotationPosition.x2 >= chartContainerBounds.width;
return outOfVerticalBounds || outOfHorizontalBounds;
};
const getAnnotationPreviewPosition = (annotationPosition) => {
return {
top: `${annotationPosition.y1}px`,
left: `${annotationPosition.x1}px`,
};
};
const onUpdateCompleteHandler = async () => {
if (chartRef.current === null || chartContainerRef.current === null) return;
if (state.annotationBeingUpdatedId && overlayState.annotationPreviewPositionSynced === false) {
const annotationPosition = chartRef.current.labelPosition(state.annotationBeingUpdatedId, 0);
if (annotationPosition) {
const annotationIsOutOfBounds = isAnnotationOutOfChartBounds(annotationPosition);
// // Update the preview position
setOverlayState((current) => ({
...current,
annotationPreviewPosition: getAnnotationPreviewPosition(annotationPosition),
annotationPreviewPositionSynced: true,
}));
}
}
};
return (
<div id="user-created-annotations" className={chartClasses.join(" ")} ref={chartContainerRef}>
<div className="overlay">
{overlayState.annotateButtonPosition !== null && (
<AnnotateButton position={overlayState.annotateButtonPosition} annotate={annotate} />
)}
{overlayState.annotationPreviewPosition && (
<AnnotationPreview
position={overlayState.annotationPreviewPosition}
text={overlayState.annotationPreviewText}
actions={{ updateAnnotationTextAndHeight, finalizeAnnotation }}
/>
)}
</div>
<Chart
ref={chartRef}
items={state.items}
layout={layout}
onClick={onClickHandler}
onDrag={onDragHandler}
onViewChange={onViewChangeHandler}
onChange={onChangeHandler}
onUpdateComplete={onUpdateCompleteHandler}
onItemInteraction={onItemInteractionHandler}
onHover={onHoverHandler}
options={{
navigation: false,
overview: false,
backgroundColor: "rgb(231, 234, 224)",
hoverDelay: 10,
zoom: {
adaptiveStyling: false,
},
labels: {
fontFamily: "Muli",
maxLength: 20,
},
iconFontFamily: "Material Icons",
selection: {},
fit: "none",
}}
animation={state.animation}
selection={state.selection}
/>
</div>
);
}
const FontReadyChart = React.lazy(() =>
document.fonts.load("24px 'Material Icons'").then(() => ({
default: UserCreatedAnnotations,
}))
);
export function Demo() {
return (
<React.Suspense fallback="">
<FontReadyChart />
</React.Suspense>
);
}
const root = createRoot(document.getElementById("regraph"));
root.render(<Demo />); export function createAnnotation(subject, text, shape = BASE_ANNOTATION.shape) {
const labels = BASE_ANNOTATION.label.map((label) => ({ ...label }));
if (text) {
const textContentLabelIndex = 1;
labels[textContentLabelIndex].text = text;
}
return {
...BASE_ANNOTATION,
subject,
label: labels,
shape,
};
}
export function isNode(item) {
return item && !item.id1 && !item.id2 && !item.subject;
}
export const icons = {
pencilEdit: "drive_file_rename_outline",
done: "check",
delete: "delete_outline",
};
export const colors = {
defaultNode: "rgb(30, 93, 220)",
selectedNode: "rgb(102, 153, 255)",
defaultLink: "rgb(202, 202, 202)",
defaultEditLabel: "rgba(254, 167, 154, 0.3)",
hoveredEditLabel: "rgb(254, 167, 154)",
annotationConnector: "rgb(241, 93, 91)",
};
export const BASE_ANNOTATION = {
connectorStyle: {
color: colors.annotationConnector,
},
color: "rgb(255, 255, 255)",
shape: {
width: 274,
height: 46,
},
border: {
radius: 3,
color: "rgb(201, 218, 252)",
},
label: [
{
backgroundColor: "transparent",
minWidth: "stretch",
minHeight: "stretch",
position: { vertical: "middle", horizontal: "center" },
textWrap: "normal",
},
{
color: "rgb(0, 0, 0)",
textAlignment: {
horizontal: "left",
},
bold: false,
position: {
vertical: "top",
horizontal: "left",
},
padding: "16 12 12 12",
textWrap: "normal",
maxHeight: 2000,
maxWidth: 274,
},
{
fontIcon: {
text: icons.pencilEdit,
color: "rgb(0, 0, 0)",
},
fontSize: 16,
backgroundColor: colors.defaultEditLabel,
position: {
horizontal: "right",
vertical: "bottom",
},
padding: 7,
margin: 8,
border: {
radius: 6,
},
},
],
};
export const items = {
n1: {
color: colors.defaultNode,
data: {
level: 1,
},
},
n2: {
color: colors.defaultNode,
data: {
level: 1,
},
},
n3: {
color: colors.defaultNode,
data: {
level: 2,
},
},
"n1-n2": {
color: colors.defaultLink,
id1: "n1",
id2: "n2",
},
"n2-n3": {
color: colors.defaultLink,
id1: "n2",
id2: "n3",
},
"n1-n3": {
color: colors.defaultLink,
id1: "n1",
id2: "n3",
},
a1: createAnnotation(["n2"], "Select a node to create an annotation", {
width: 274,
height: 73,
}),
};
export const annotationPositions = {
a1: {
distance: 80,
},
}; <!doctype html>
<html>
<head>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="regraph" style="height: 100vh"></div>
<script type="module" src="./code.jsx"></script>
</body>
</html> /* Import for Google's Material Icons */
@import url("https://fonts.googleapis.com/icon?family=Material+Icons");
#user-created-annotations {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
height: 100%;
}
#user-created-annotations .overlay {
position: absolute;
width: 100%;
}
/* Common base style applied across the buttons */
#user-created-annotations .button {
border: none;
cursor: pointer;
font-family: "Muli", sans-serif;
margin: 0px;
background-color: rgb(241, 93, 91);
&:hover {
background-color: rgb(254, 167, 154);
}
}
#annotate-button {
position: absolute;
z-index: 399;
width: 27px;
height: 27px;
border-radius: 20px 20px 20px 0px;
padding: 2px 0px 0px 0px;
}
#annotation-preview {
display: flex;
position: absolute;
flex-direction: column;
padding: 10px;
border-radius: 3px;
width: 274px;
max-width: 274px;
gap: 4px;
background-color: rgb(255, 255, 255);
z-index: 499;
box-sizing: border-box;
}
#annotation-upper {
display: flex;
align-items: center;
}
#annotation-text {
resize: none;
border: none;
font-family: "Muli", sans-serif;
width: 100%;
height: auto;
overflow: hidden;
line-height: 15px;
font-size: 14px;
background-color: rgb(255, 255, 255);
&:focus {
outline: none;
}
}
#ghost-done-button,
#edit-controls > button {
width: 26px;
height: 26px;
border-radius: 3px;
border: none;
align-items: center;
justify-content: center;
display: flex;
}
/* Place holder disabled button for when we first
create an empty annotation */
#ghost-done-button {
padding: 0px;
background-color: #213d450d;
cursor: auto;
&:disabled > span {
color: black !important;
}
}
#annotation-toolbar {
display: flex;
justify-content: flex-end;
}
#edit-controls {
display: flex;
gap: 6px;
}
#edit-controls > button {
cursor: pointer;
}
#delete-button {
background-color: rgba(254, 167, 154, 0.3);
&:hover {
background-color: rgb(254, 167, 154);
}
}
#user-created-annotations .material-icons {
font-size: 16px;
}
/* Hidden span elemenet to check whether we need to add newlines to annotation text */
#user-created-annotations #text-measure {
font-family: "Muli", sans-serif;
line-height: 15px;
font-size: 14px;
padding: 2px;
visibility: hidden;
}