//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Create and edit annotations on nodes.
import KeyLines from "keylines";
import {
  colours,
  data,
  createAnnotation,
} from "./usercreatedannotations-data.js";

let chart;
let annotationIdCount = 1;
let nodeIdWithAnnotateButton = null;
let annotationBeingUpdated = null;
let hoveredAnnotationId = null;
let annotationToolbarShown = false;
const editLabelIndex = 2;
const chartContainer = document.getElementById("klchart");
const annotateButton = document.getElementById("annotate-button");
const annotationPreview = document.getElementById("annotation-preview");
const annotationTextArea = document.getElementById("annotation-text");
const textMeasurementSpan = document.getElementById("text-measure");

async function annotate() {
  const annotationSubjects = getSelectedNodeIds();
  // Clear chart selection when an annotation is created
  chart.selection([]);
  hideAnnotateButton();
  // Reset the pointer cursor override from the edit label
  setCursor(null);

  // Add the initial annotation to the chart to help with overlay positioning
  annotationBeingUpdated = createAnnotation(
    `a${++annotationIdCount}`,
    annotationSubjects
  );
  await chart.merge(annotationBeingUpdated);

  // Ensure the new annotations and its subjects are in view if required
  await fitViewToAnnotation();
  placeAnnotationPreview();
  setupAnnotationPreview();
}

async function finalizeAnnotation(action) {
  if (annotationBeingUpdated === null) return;
  if (action === undefined || action === "dismiss") {
    const annotationText = annotationBeingUpdated.t[1].t;
    action = annotationText === "" ? "delete" : "dismiss";
  }
  if (action === "delete") {
    chart.removeItem(annotationBeingUpdated.id);
  } else {
    const windowSelection = window.getSelection();
    // Clear text highlighting
    if (windowSelection.rangeCount !== 0) {
      windowSelection.removeAllRanges();
    }
  }
  annotationPreview.style.display = "none";
  annotationBeingUpdated = null;
}

async function initialiseAnnotationEdit() {
  chart.selection([]);
  placeAnnotationPreview();
  // Preconfigure the preview element for the annotation we are editing
  setupAnnotationPreview();
}

// Determines whether an annotation is currently in view
function isAnnotationOutOfBounds(id) {
  const annotationPosition = chart.labelPosition(id, 0);
  const chartContainerBounds = chartContainer.getBoundingClientRect();
  const outOfVerticalBounds =
    annotationPosition.y1 <= 0 ||
    annotationPosition.y2 >= chartContainerBounds.height;
  const outOfHorizontalBounds =
    annotationPosition.x1 <= 0 ||
    annotationPosition.x2 >= chartContainerBounds.width;
  return outOfVerticalBounds || outOfHorizontalBounds;
}

// Fits the view to the annotation and its subjects if required
async function fitViewToAnnotation() {
  // Check whether we need to adjust the view to allow the annotation space
  if (isAnnotationOutOfBounds(annotationBeingUpdated.id)) {
    await chart.zoom("fit", {
      ids: [
        annotationBeingUpdated.id,
        ...annotationBeingUpdated.subjects.map((id) => id),
      ],
      animate: true,
      time: 400,
    });
  }
}

function getSelectedNodeIds() {
  return chart.selection().filter((id) => chart.getItem(id).type === "node");
}

function getLatestSelectedNodeId() {
  const selectedNodeIds = getSelectedNodeIds();
  return selectedNodeIds[selectedNodeIds.length - 1];
}

/* Positions the annotate button based on the current
   chart zoom level and attached node position */
function updateAnnotateButtonPosition() {
  const nodeBaseSize = 27;
  const node = chart.getItem(nodeIdWithAnnotateButton);
  const { x, y } = chart.viewCoordinates(node.x, node.y);
  const { zoom } = chart.viewOptions();
  const nodeSize = nodeBaseSize * zoom;
  annotateButton.style.left = `${x + nodeSize}px`;
  annotateButton.style.top = `${y - annotateButton.clientHeight - nodeSize}px`;
}

// Hides / shows the edit tool bar on the annotation preview
function toggleAnnotationToolbar(show) {
  const annotationToolbar = document.getElementById("annotation-toolbar");
  const ghostDoneButton = document.getElementById("ghost-done-button");
  const toolbarDisplay = show ? "flex" : "none";
  const ghostDoneButtonDisplay = show ? "none" : "block";
  annotationToolbar.style.display = toolbarDisplay;
  ghostDoneButton.style.display = ghostDoneButtonDisplay;
  annotationToolbarShown = show;
}

function placeAnnotationPreview() {
  // Get the position of the annotation's background label to place the preview
  const annotationPosition = chart.labelPosition(annotationBeingUpdated.id, 0);
  annotationPreview.style.top = `${annotationPosition.y1}px`;
  annotationPreview.style.left = `${annotationPosition.x1}px`;
  annotationPreview.style.display = "flex";
  annotationTextArea.focus();
}

function updateTextAreaHeight() {
  annotationTextArea.style.height = "auto";
  annotationTextArea.style.height = `${annotationTextArea.scrollHeight}px`;
}

/* KeyLines doesn't wrap strings without spaces so add new lines manually
   to handle long words which exceed the annotation body width */
function wrapLongWord(word) {
  let wrappedText = "";
  let tempLine = "";
  for (const char of word) {
    // Add characters to the measurement span incrementally to check if the word exceeds the annotation bounds
    textMeasurementSpan.textContent = `${tempLine}${char}`;
    if (
      textMeasurementSpan.getBoundingClientRect().width >
      annotationTextArea.clientWidth
    ) {
      wrappedText += `${tempLine}\n`;
      tempLine = char;
    } else {
      tempLine += char;
    }
  }
  // Add remaining part of word
  wrappedText += tempLine;
  return wrappedText;
}

function formatAnnotationInputText(string) {
  let formattedText = "";
  const words = string.split(" ");
  words.forEach((word) => {
    textMeasurementSpan.textContent = word;
    /* Use the 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 (
      textMeasurementSpan.getBoundingClientRect().width >
      annotationTextArea.clientWidth
    ) {
      formattedText += wrapLongWord(word) + " ";
    } else {
      formattedText += word + " ";
    }
  });
  return formattedText.trim();
}

/* Updates the chart annotation text & height properties to ensure the preview
   element can be synced to the correct position */
async function updateAnnotationTextAndHeight(text, height) {
  const annotationBeingUpdatedLabels = annotationBeingUpdated.t;
  annotationBeingUpdatedLabels[1].t = text;
  await chart.setProperties({
    id: annotationBeingUpdated.id,
    t: annotationBeingUpdatedLabels,
    h: height,
  });
}

async function onAnnotationInput() {
  // Remove any trailing whitespaces and new lines (\n) that may have been added from previous long words
  const cleanTextInput = annotationTextArea.value.replace(/\n/g, "").trim();
  const cleanTextLength = cleanTextInput.length;
  const showToolbar = cleanTextLength > 0 && !annotationToolbarShown;
  // Ensure annotation preview height grows / shrinks with the text input
  updateTextAreaHeight();

  if (showToolbar) {
    toggleAnnotationToolbar(true);
  } else if (annotationToolbarShown && cleanTextLength === 0) {
    // If we only have white space hide the toolbar
    toggleAnnotationToolbar(false);
  }

  // Format text so long words are wrapped correctly before updating chart annotation text
  const formattedText = formatAnnotationInputText(cleanTextInput);
  // Update the chart annotation text & height so we can position the preview
  await updateAnnotationTextAndHeight(
    formattedText,
    annotationPreview.offsetHeight
  );
  // Sync the preview overlay position with the chart annotation position
  placeAnnotationPreview();
}

async function onAnnotationKeyDown(e) {
  const { key } = e;
  if (annotationBeingUpdated !== null) {
    if (key === "Escape") {
      finalizeAnnotation();
    }
    if (key === "Enter") {
      // Don't create new lines for enter
      e.preventDefault();
      await finalizeAnnotation("dismiss");
    }
  }
}

// Sets up the initial state for the preview based on the annotation being edited
function setupAnnotationPreview() {
  const { t } = annotationBeingUpdated;
  const annotationText = t[1].t;
  annotationTextArea.value = annotationText;
  toggleAnnotationToolbar(annotationText !== "");
  updateTextAreaHeight();
}

function hideAnnotateButton() {
  annotateButton.style.display = "none";
  nodeIdWithAnnotateButton = null;
}

function placeAnnotateButtonOnNode(nodeId) {
  nodeIdWithAnnotateButton = nodeId;
  annotateButton.style.display = "block";
  updateAnnotateButtonPosition();
}

async function onSelectionChange() {
  // remove the glyph from the previous selected node if we have one
  if (nodeIdWithAnnotateButton) {
    hideAnnotateButton();
  }
  if (getSelectedNodeIds().length > 0) {
    placeAnnotateButtonOnNode(getLatestSelectedNodeId());
  }
}

async function onClick({ id, subItem, preventDefault }) {
  // If the chart is clicked while an annotation is being edited finalize the annotation
  if (annotationBeingUpdated !== null) {
    finalizeAnnotation();
    preventDefault();
    return;
  }

  if (id) {
    const clickedItem = chart.getItem(id);
    if (
      clickedItem.type === "annotation" &&
      subItem?.index === editLabelIndex
    ) {
      if (nodeIdWithAnnotateButton !== null) {
        hideAnnotateButton();
      }
      // Prevent selection from being cleared when an annotation is clicked
      preventDefault();
      setCursor(null);
      annotationBeingUpdated = clickedItem;
      await initialiseAnnotationEdit();
    }
  }
}

async function onDragStart() {
  // If the chart is dragged while an annotation is being edited finalize the annotation
  if (annotationBeingUpdated !== null) {
    await finalizeAnnotation();
  }
}

function onDragMove({ id }) {
  // Update the annotate button position while dragging the attached node
  if (id && getSelectedNodeIds().includes(id)) {
    updateAnnotateButtonPosition();
  }
}

function onViewChange() {
  // Update the annotate button position while zooming and panning the chart
  if (nodeIdWithAnnotateButton) {
    updateAnnotateButtonPosition();
  }
}

/* Ensures annotate button position is updated when nodes
   are moved using the keyboard arrow keys */
function onPreChange({ type }) {
  if (type === "move" && nodeIdWithAnnotateButton !== null) {
    setTimeout(() => {
      updateAnnotateButtonPosition();
    });
  }
}

async function onHover({ id, subItem }) {
  const hoveringEditLabel = id && subItem?.index === editLabelIndex;
  if (hoveringEditLabel) {
    hoveredAnnotationId = id;
  }
  // Apply styling updates when the chart annotation edit button is hovered
  if (hoveredAnnotationId !== null) {
    await updateEditLabelHoverState(
      hoveringEditLabel,
      chart.getItem(hoveredAnnotationId)
    );
    const cursorUpdateType = hoveringEditLabel ? "pointer" : "null";
    setCursor(cursorUpdateType);
  }
  if (id === null) {
    hoveredAnnotationId = null;
  }
}

async function updateEditLabelHoverState(hovered, hoveredAnnotation) {
  const hoveredAnnotationLabel = hoveredAnnotation?.t;
  if (hoveredAnnotationLabel) {
    const editLabelHoverColour = hovered
      ? colours.hoveredEditLabel
      : colours.defaultEditLabel;
    hoveredAnnotation.t[editLabelIndex].fbc = editLabelHoverColour;
    await chart.setProperties({
      id: hoveredAnnotation.id,
      t: hoveredAnnotation.t,
    });
  }
}

// Enable cursor styling override while interacting with the chart
function setCursor(type) {
  // get all non-cursor classes
  const classes = Array.from(chartContainer.classList).filter(
    (cn) => !cn.match(/^cursor-/)
  );
  if (type) {
    classes.push(`cursor-${type}`);
  }
  chartContainer.className = classes.join(" ");
}

function initialiseInteractions() {
  chart.on("selection-change", onSelectionChange);
  chart.on("click", onClick);
  chart.on("hover", onHover);
  chart.on("drag-start", onDragStart);
  chart.on("drag-move", onDragMove);
  chart.on("prechange", onPreChange);
  chart.on("view-change", onViewChange);
  const confirmAnnotationUpdateButton = document.getElementById("done");
  const deleteAnnotationButton = document.getElementById("delete");
  annotateButton.addEventListener("click", annotate);
  confirmAnnotationUpdateButton.addEventListener("click", () =>
    finalizeAnnotation("dismiss")
  );
  deleteAnnotationButton.addEventListener("click", () =>
    finalizeAnnotation("delete")
  );
  annotationTextArea.addEventListener("input", onAnnotationInput);
  annotationTextArea.addEventListener("keydown", onAnnotationKeyDown);
}

async function startKeyLines() {
  const options = {
    handMode: true,
    backColour: "rgb(209, 224, 230)",
    overview: false,
    navigation: false,
    selectedNode: {
      c: colours.selectedNode,
    },
    selectedLink: {},
    hover: 10,
    fontFamily: "Muli",
    iconFontFamily: "CI-Icons",
  };

  chart = await KeyLines.create({ container: "klchart", options });
  await chart.load(data);
  await chart.layout("sequential", {
    level: "level",
    tightness: 0,
    stretch: 0.54,
    animate: false,
  });

  await chart.zoom("fit", { ids: ["n1", "n2", "n3"] });

  initialiseInteractions();
}

// if you need font icons, keep this.
// If not, call startKeyLines() from the listener on the last line and delete this function
function loadWebFonts() {
  document.fonts.load('24px "CI-Icons"').then(startKeyLines);
}

window.addEventListener("DOMContentLoaded", loadWebFonts);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
  <head>
    <meta charset="UTF-8">
    <title>User-Created Annotations</title>
    <link rel="stylesheet" type="text/css" href="/css/keylines.css">
    <link rel="stylesheet" type="text/css" href="/css/minimalsdk.css">
    <link rel="stylesheet" type="text/css" href="/css/sdk-layout.css">
    <link rel="stylesheet" type="text/css" href="/css/demo.css">
    <link rel="stylesheet" type="text/css" href="/css/ci-icons.css">
    <link rel="stylesheet" type="text/css" href="/usercreatedannotations.css">
    <script src="/vendor/jquery.js" defer type="text/javascript"></script>
    <script id="keylines" src="/public/keylines.umd.cjs" defer type="text/javascript"></script>
    <script src="/usercreatedannotations.js" crossorigin="use-credentials" defer type="module"></script>
  </head>
  <body>
    <div class="chart-wrapper demo-cols">
      <div class="tab-content-panel flex1" id="lhs" data-tab-group="rhs">
        <div class="toggle-content is-visible" id="lhsVisual" style="width:100%; height: 100%;">
          <div class="klchart" id="klchart">
            <div class="overlay" oncontextmenu="return false;">
              <button class="overlay-button" id="annotate-button"><span class="ci-icon-annotate"></span></button>
              <div id="annotation-preview" oncontextmenu="return false;">
                <div id="annotation-upper">
                  <textarea id="annotation-text" placeholder="Add comment" rows="1" spellcheck="false"></textarea>
                  <button class="control-button" id="ghost-done-button" disabled><span class="ci-icon-check"></span></button>
                </div>
                <div id="annotation-toolbar">
                  <div id="edit-controls">
                    <button class="overlay-button" id="delete"><span class="ci-icon-bin"></span></button>
                    <button class="overlay-button" id="done"> <span class="ci-icon-check"></span></button>
                  </div>
                </div>
              </div><span id="text-measure"></span>
            </div>
          </div>
        </div>
      </div>
      <div class="rhs citext closed" id="demorhscontent">
        <div class="title-bar">
          <svg viewBox="0 0 360 60" style="max-width: 360px; max-height: 60px; font-size: 24px; font-family: Raleway; flex: 1;">
            <text x="0" y="38" style="width: 100%;">User-Created Annotations</text>
          </svg>
        </div>
        <div class="tab-content-panel" data-tab-group="rhs">
          <div class="toggle-content is-visible tabcontent" id="controlsTab">
            <form autocomplete="off" onsubmit="return false" id="rhsForm">
              <div class="cicontent">
                <p>Select a node and click the<span class="overlay-button inline-annotate-button"><i class="ci-icon-annotate"></i></span>button to create an annotation.</p>
                <p>Each annotation has a <span class="overlay-button inline-edit-button"><i class="ci-icon-pencil-edit"></i></span>button to edit the text or delete the annotation.</p>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
/* Enable cursor overriding while hovering the chart */
.cursor-pointer canvas {
  cursor: pointer !important;
}

.overlay {
  position: absolute;
  width: 100%;
  height: 100%;
  overflow: hidden;
  /* Allow chart interaction through the overlay div*/
  pointer-events: none;
}

.overlay-button {
  background-color: rgb(241, 93, 91);
  border: none;
  cursor: pointer !important;
  color: black;
  &:hover {
    background-color: rgb(254, 167, 154);
  }

  &:not(:disabled):hover {
    background-color: rgb(254, 167, 154) !important;
    > span {
      color: rgb(0, 0, 0);
    }
  }

  &:disabled {
    background-color: rgba(0, 163, 255);
    border: none;
    cursor: not-allowed !important;
    color: black !important;

    &:hover {
      border: none;
      background-color: rgba(0, 163, 255);
    }
  }

  &:focus {
    background-color: rgb(241, 93, 91) !important;
    color: black !important;
  }
}

/* Overlay button (position (top/left) is updated from JS) */
#annotate-button {
  position: absolute;
  z-index: 1000;
  width: 32px;
  height: 32px;
  border-radius: 20px 20px 20px 0px !important;
  padding: 0px;
  pointer-events: auto;
  display: none;
}

/* Preview overlay*/
#annotation-preview {
  display: none; /* Initially none otherwise 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;
  pointer-events: auto;
}

#annotation-upper {
  display: flex;
  align-items: center;
}

#annotation-toolbar {
  display: none;
  justify-content: flex-end;
  padding: 0px;
  width: 100%;
}

#edit-controls {
  display: flex;
  gap: 6px;
}

#edit-controls > button {
  cursor: pointer;
  border: none;
  width: 26px;
  height: 26px;
  padding: 0px;
  border-radius: 3px !important;

  &:hover {
    background-color: (84, 193, 255);
  }
}

#delete {
  background-color: rgb(255, 229, 222);
}

#annotation-text {
  resize: none;
  border: none;
  font-family: 'Muli';
  width: 100%;
  height: auto;
  overflow: hidden;
  line-height: 16px;
  font-size: 14px;
  background-color: rgb(255, 255, 255);
  &:focus {
    outline: none;
  }
}

#text-measure {
  font-family: 'Muli';
  line-height: 15px;
  font-size: 14px;
  padding: 2px;
  visibility: hidden;
}

/* Place holder disabled button for when we first
     create an empty annotation */
#ghost-done-button {
  width: 26px;
  height: 26px;
  padding: 0px;
  background-color: #213d450d;
  border: none;
  border-radius: 3px;
  cursor: auto;

  &:disabled > span {
    color: black !important;
  }
}

.ci-icon-check {
  font-size: 10px;
  margin-top: 4px;
}

.ci-icon-bin {
  font-size: 13px;
  margin-top: 2px;
}

.cicontent > p > span {
  display: inline-block;
  width: 22px;
  height: 22px;
  vertical-align: bottom;
  pointer-events: none;
  background-color: rgb(234, 234, 234);

  &:hover {
    background-color: initial;
  }

  > i {
    font-size: 10px;
  }
}

.inline-edit-button {
  margin: 0px 6px 0px 4px;
  padding: 0px 0px 0px 6px;
}

.inline-annotate-button {
  border-radius: 20px 20px 20px 0px;
  margin: 0px 4px 0px 5px;
  padding: 0px 0px 0px 6px;
}
Loading source