diff --git a/packages/core/src/utilities/windowLevel.ts b/packages/core/src/utilities/windowLevel.ts
index 70b12a8b01..a410dfada5 100644
--- a/packages/core/src/utilities/windowLevel.ts
+++ b/packages/core/src/utilities/windowLevel.ts
@@ -30,8 +30,8 @@ function toWindowLevel(
*
* LINEAR (default):
* - Uses the DICOM standard formula from C.11.2.1.2.1:
- * if x <= c - 0.5 - (w-1)/2 => lower bound
- * if x > c - 0.5 + (w-1)/2 => upper bound
+ * if x {'<='} c - 0.5 - (w-1)/2 {'=>'} lower bound
+ * if x {'<'} c - 0.5 + (w-1)/2 {'=>'} upper bound
*
* LINEAR_EXACT (C.11.2.1.3.2):
* - Uses:
diff --git a/packages/tools/examples/dynamicallyAddAnnotations/index.ts b/packages/tools/examples/dynamicallyAddAnnotations/index.ts
index b7f817a566..721dfe1ec5 100644
--- a/packages/tools/examples/dynamicallyAddAnnotations/index.ts
+++ b/packages/tools/examples/dynamicallyAddAnnotations/index.ts
@@ -17,6 +17,7 @@ import {
ArrowAnnotateTool,
CircleROITool,
EllipticalROITool,
+ LabelTool,
LengthTool,
ProbeTool,
RectangleROITool,
@@ -32,6 +33,7 @@ console.debug(
);
const tools = [
+ LabelTool,
AngleTool,
ArrowAnnotateTool,
EllipticalROITool,
diff --git a/packages/tools/examples/dynamicallyAddAnnotations/labelToolUI.ts b/packages/tools/examples/dynamicallyAddAnnotations/labelToolUI.ts
new file mode 100644
index 0000000000..dda254b055
--- /dev/null
+++ b/packages/tools/examples/dynamicallyAddAnnotations/labelToolUI.ts
@@ -0,0 +1,81 @@
+import { getEnabledElementByViewportId, utilities } from '@cornerstonejs/core';
+import type { Point2 } from '@cornerstonejs/core/types';
+import { LabelTool } from '@cornerstonejs/tools';
+import { typeToIdMap, typeToStartIdMap, typeToEndIdMap } from './constants';
+
+function getInputValue(form: HTMLFormElement, inputId: string): number {
+ return Number((form.querySelector(`#${inputId}`) as HTMLInputElement).value);
+}
+
+function getCoordinates(
+ form: HTMLFormElement,
+ type: 'canvas' | 'image'
+): Point2 {
+ const position: Point2 = [
+ getInputValue(form, `${typeToStartIdMap[type]}-1`),
+ getInputValue(form, `${typeToStartIdMap[type]}-2`),
+ ];
+ return position;
+}
+
+function createFormElement(): HTMLFormElement {
+ const form = document.createElement('form');
+ form.style.marginBottom = '10px';
+
+ ['canvas', 'image'].forEach((coordType) => {
+ form.innerHTML += `
+
+
+
+
+
+
+
+
+
+ `;
+ });
+
+ return form;
+}
+
+function addButtonListeners(form: HTMLFormElement): void {
+ const buttons = form.querySelectorAll('button');
+ buttons.forEach((button) => {
+ button.addEventListener('click', () => {
+ const [type, viewportType] = button.id.split('-') as [
+ 'canvas' | 'image',
+ keyof typeof typeToIdMap
+ ];
+ const enabledElement = getEnabledElementByViewportId(
+ typeToIdMap[viewportType]
+ );
+ const viewport = enabledElement.viewport;
+ const coords = getCoordinates(form, type);
+ const textInput = form.querySelector(`#${type}-text`) as HTMLInputElement;
+ const text = textInput ? textInput.value : '';
+ const currentImageId = viewport.getCurrentImageId() as string;
+
+ const position =
+ type === 'image'
+ ? utilities.imageToWorldCoords(currentImageId, coords)
+ : viewport.canvasToWorld(coords);
+
+ console.log('Adding label at:', position);
+
+ LabelTool.hydrate(viewport.id, position, text);
+ });
+ });
+}
+
+export function createLabelToolUI(): HTMLFormElement {
+ const form = createFormElement();
+ addButtonListeners(form);
+ return form;
+}
diff --git a/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts b/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts
index 250440311d..a098a345f4 100644
--- a/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts
+++ b/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts
@@ -6,6 +6,7 @@ import { createProbeToolUI } from './probeToolUI';
import { createRectangleROIToolUI } from './rectangleROIToolUI';
import { createCircleROIToolUI } from './circleROIToolUI';
import { createSplineROIToolUI } from './splineROIToolUI';
+import { createLabelToolUI } from './labelToolUI';
interface ToolUIConfig {
toolName: string;
@@ -31,6 +32,9 @@ function createToolUI(toolName: string, config: ToolUIConfig): ToolUI | null {
case 'EllipticalROI':
forms = [createEllipseROIToolUI()];
break;
+ case 'Label':
+ forms = [createLabelToolUI()];
+ break;
case 'Length':
forms = [createLengthToolUI()];
break;
diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts
index 0692f37e75..515fd98909 100644
--- a/packages/tools/src/index.ts
+++ b/packages/tools/src/index.ts
@@ -32,6 +32,7 @@ import {
StackScrollTool,
PlanarRotateTool,
MIPJumpToClickTool,
+ LabelTool,
LengthTool,
HeightTool,
ProbeTool,
@@ -106,6 +107,7 @@ export {
PlanarRotateTool,
MIPJumpToClickTool,
// Annotation Tools
+ LabelTool,
LengthTool,
HeightTool,
CrosshairsTool,
diff --git a/packages/tools/src/tools/annotation/LabelTool.ts b/packages/tools/src/tools/annotation/LabelTool.ts
new file mode 100644
index 0000000000..6ee0a12513
--- /dev/null
+++ b/packages/tools/src/tools/annotation/LabelTool.ts
@@ -0,0 +1,539 @@
+import { Events } from '../../enums';
+import {
+ getEnabledElement,
+ utilities as csUtils,
+ getEnabledElementByViewportId,
+} from '@cornerstonejs/core';
+import type { Types } from '@cornerstonejs/core';
+
+import { AnnotationTool } from '../base';
+import {
+ addAnnotation,
+ getAnnotations,
+ removeAnnotation,
+} from '../../stateManagement/annotation/annotationState';
+
+import { drawTextBox as drawTextBoxSvg } from '../../drawingSvg';
+import { state } from '../../store/state';
+import { getViewportIdsWithToolToRender } from '../../utilities/viewportFilters';
+import triggerAnnotationRenderForViewportIds from '../../utilities/triggerAnnotationRenderForViewportIds';
+import {
+ triggerAnnotationCompleted,
+ triggerAnnotationModified,
+} from '../../stateManagement/annotation/helpers/state';
+
+import {
+ resetElementCursor,
+ hideElementCursor,
+} from '../../cursors/elementCursor';
+
+import type {
+ EventTypes,
+ PublicToolProps,
+ ToolProps,
+ SVGDrawingHelper,
+ Annotation,
+} from '../../types';
+import type { LabelAnnotation } from '../../types/ToolSpecificAnnotationTypes';
+import type { StyleSpecifier } from '../../types/AnnotationStyle';
+
+class LabelTool extends AnnotationTool {
+ static toolName;
+
+ editData: {
+ annotation: Annotation;
+ viewportIdsToRender: string[];
+ newAnnotation?: boolean;
+ hasMoved?: boolean;
+ } | null;
+ isDrawing: boolean;
+ isHandleOutsideImage: boolean;
+
+ constructor(
+ toolProps: PublicToolProps = {},
+ defaultToolProps: ToolProps = {
+ supportedInteractionTypes: ['Mouse', 'Touch'],
+ configuration: {
+ shadow: true,
+ getTextCallback,
+ changeTextCallback,
+ preventHandleOutsideImage: false,
+ },
+ }
+ ) {
+ super(toolProps, defaultToolProps);
+ }
+
+ // Not necessary for this tool but needs to be defined since it's an abstract
+ // method from the parent class.
+ isPointNearTool(): boolean {
+ return false;
+ }
+
+ static hydrate = (
+ viewportId: string,
+ position: Types.Point3,
+ text: string,
+ options?: {
+ annotationUID?: string;
+ }
+ ): LabelAnnotation => {
+ const enabledElement = getEnabledElementByViewportId(viewportId);
+ if (!enabledElement) {
+ return;
+ }
+ const { viewport } = enabledElement;
+ const FrameOfReferenceUID = viewport.getFrameOfReferenceUID();
+
+ const { viewPlaneNormal, viewUp } = viewport.getCamera();
+
+ // This is a workaround to access the protected method getReferencedImageId
+ // we should make those static too
+ const instance = new this();
+
+ const referencedImageId = instance.getReferencedImageId(
+ viewport,
+ position,
+ viewPlaneNormal,
+ viewUp
+ );
+
+ const annotation = {
+ annotationUID: options?.annotationUID || csUtils.uuidv4(),
+ data: {
+ text,
+ handles: {
+ points: [position],
+ },
+ },
+ highlighted: false,
+ autoGenerated: false,
+ invalidated: false,
+ isLocked: false,
+ isVisible: true,
+ metadata: {
+ toolName: instance.getToolName(),
+ viewPlaneNormal,
+ FrameOfReferenceUID,
+ referencedImageId,
+ ...options,
+ },
+ };
+
+ addAnnotation(annotation, viewport.element);
+
+ triggerAnnotationRenderForViewportIds([viewport.id]);
+ };
+
+ /**
+ * Based on the current position of the mouse and the current imageId to create
+ * a Length Annotation and stores it in the annotationManager
+ *
+ * @param evt - EventTypes.NormalizedMouseEventType
+ * @returns The annotation object.
+ *
+ */
+ addNewAnnotation = (
+ evt: EventTypes.InteractionEventType
+ ): LabelAnnotation => {
+ const eventDetail = evt.detail;
+ const { currentPoints, element } = eventDetail;
+ const worldPos = currentPoints.world;
+ const enabledElement = getEnabledElement(element);
+ const { viewport } = enabledElement;
+
+ hideElementCursor(element);
+ this.isDrawing = true;
+
+ const camera = viewport.getCamera();
+ const { viewPlaneNormal, viewUp } = camera;
+
+ const referencedImageId = this.getReferencedImageId(
+ viewport,
+ worldPos,
+ viewPlaneNormal,
+ viewUp
+ );
+
+ const FrameOfReferenceUID = viewport.getFrameOfReferenceUID();
+
+ const annotation = {
+ annotationUID: null as string,
+ highlighted: true,
+ invalidated: true,
+ metadata: {
+ toolName: this.getToolName(),
+ viewPlaneNormal: [...viewPlaneNormal],
+ viewUp: [...viewUp],
+ FrameOfReferenceUID,
+ referencedImageId,
+ ...viewport.getViewReference({ points: [worldPos] }),
+ },
+ data: {
+ text: '',
+ handles: {
+ points: [[...worldPos], [...worldPos]],
+ },
+ label: '',
+ },
+ };
+
+ addAnnotation(annotation, element);
+
+ const viewportIdsToRender = getViewportIdsWithToolToRender(
+ element,
+ this.getToolName()
+ );
+
+ evt.preventDefault();
+
+ triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+ console.log('Annotation added:', annotation);
+ this.configuration.getTextCallback((text) => {
+ if (!text) {
+ removeAnnotation(annotation.annotationUID);
+ triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+ this.isDrawing = false;
+ return;
+ }
+ annotation.data.text = text;
+
+ triggerAnnotationCompleted(annotation);
+
+ triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+ });
+
+ this.createMemo(element, annotation, { newAnnotation: true });
+
+ return annotation;
+ };
+
+ toolSelectedCallback() {}
+
+ handleSelectedCallback(
+ evt: EventTypes.InteractionEventType,
+ annotation: LabelAnnotation
+ ): void {
+ const eventDetail = evt.detail;
+ const { element } = eventDetail;
+
+ annotation.highlighted = true;
+
+ const viewportIdsToRender = getViewportIdsWithToolToRender(
+ element,
+ this.getToolName()
+ );
+
+ // Find viewports to render on drag.
+
+ this.editData = {
+ //handle, // This would be useful for other tools with more than one handle
+ annotation,
+ viewportIdsToRender,
+ };
+ this._activateModify(element);
+
+ hideElementCursor(element);
+
+ triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+
+ evt.preventDefault();
+ }
+
+ _endCallback = (evt: EventTypes.InteractionEventType): void => {
+ const eventDetail = evt.detail;
+ const { element } = eventDetail;
+
+ const { annotation, viewportIdsToRender, newAnnotation } = this.editData;
+
+ const { viewportId, renderingEngine } = getEnabledElement(element);
+
+ this._deactivateModify(element);
+
+ resetElementCursor(element);
+
+ if (newAnnotation) {
+ this.createMemo(element, annotation, { newAnnotation });
+ }
+
+ this.editData = null;
+ this.isDrawing = false;
+ this.doneEditMemo();
+
+ if (
+ this.isHandleOutsideImage &&
+ this.configuration.preventHandleOutsideImage
+ ) {
+ removeAnnotation(annotation.annotationUID);
+ }
+
+ triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+
+ if (newAnnotation) {
+ triggerAnnotationCompleted(annotation);
+ }
+ };
+
+ _dragCallback = (evt: EventTypes.InteractionEventType): void => {};
+
+ _doneChangingTextCallback(element, annotation, updatedText): void {
+ annotation.data.text = updatedText;
+
+ const enabledElement = getEnabledElement(element);
+ const { renderingEngine } = enabledElement;
+
+ const viewportIdsToRender = getViewportIdsWithToolToRender(
+ element,
+ this.getToolName()
+ );
+ triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+
+ // Dispatching annotation modified
+ triggerAnnotationModified(annotation, element);
+ }
+
+ cancel = (element: HTMLDivElement) => {
+ // If it is mid-draw or mid-modify
+ if (this.isDrawing) {
+ this.isDrawing = false;
+ this._deactivateModify(element);
+ resetElementCursor(element);
+
+ const { annotation, viewportIdsToRender, newAnnotation } = this.editData;
+ const { data } = annotation;
+
+ annotation.highlighted = false;
+ data.handles.activeHandleIndex = null;
+
+ triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+
+ if (newAnnotation) {
+ triggerAnnotationCompleted(annotation);
+ }
+
+ this.editData = null;
+ return annotation.annotationUID;
+ }
+ };
+
+ _activateModify = (element: HTMLDivElement) => {
+ state.isInteractingWithTool = true;
+
+ element.addEventListener(
+ Events.MOUSE_UP,
+ this._endCallback as EventListener
+ );
+ element.addEventListener(
+ Events.MOUSE_DRAG,
+ this._dragCallback as EventListener
+ );
+ element.addEventListener(
+ Events.MOUSE_CLICK,
+ this._endCallback as EventListener
+ );
+
+ element.addEventListener(
+ Events.TOUCH_TAP,
+ this._endCallback as EventListener
+ );
+ element.addEventListener(
+ Events.TOUCH_END,
+ this._endCallback as EventListener
+ );
+ element.addEventListener(
+ Events.TOUCH_DRAG,
+ this._dragCallback as EventListener
+ );
+ };
+
+ _deactivateModify = (element: HTMLDivElement) => {
+ state.isInteractingWithTool = false;
+
+ element.removeEventListener(
+ Events.MOUSE_UP,
+ this._endCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.MOUSE_DRAG,
+ this._dragCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.MOUSE_CLICK,
+ this._endCallback as EventListener
+ );
+
+ element.removeEventListener(
+ Events.TOUCH_TAP,
+ this._endCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.TOUCH_DRAG,
+ this._dragCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.TOUCH_END,
+ this._endCallback as EventListener
+ );
+ };
+
+ _activateDraw = (element: HTMLDivElement) => {
+ state.isInteractingWithTool = true;
+
+ element.addEventListener(
+ Events.MOUSE_UP,
+ this._endCallback as EventListener
+ );
+ element.addEventListener(
+ Events.MOUSE_DRAG,
+ this._dragCallback as EventListener
+ );
+ element.addEventListener(
+ Events.MOUSE_MOVE,
+ this._dragCallback as EventListener
+ );
+ element.addEventListener(
+ Events.MOUSE_CLICK,
+ this._endCallback as EventListener
+ );
+
+ element.addEventListener(
+ Events.TOUCH_TAP,
+ this._endCallback as EventListener
+ );
+ element.addEventListener(
+ Events.TOUCH_END,
+ this._endCallback as EventListener
+ );
+ element.addEventListener(
+ Events.TOUCH_DRAG,
+ this._dragCallback as EventListener
+ );
+ };
+
+ _deactivateDraw = (element: HTMLDivElement) => {
+ state.isInteractingWithTool = false;
+
+ element.removeEventListener(
+ Events.MOUSE_UP,
+ this._endCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.MOUSE_DRAG,
+ this._dragCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.MOUSE_MOVE,
+ this._dragCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.MOUSE_CLICK,
+ this._endCallback as EventListener
+ );
+
+ element.removeEventListener(
+ Events.TOUCH_TAP,
+ this._endCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.TOUCH_END,
+ this._endCallback as EventListener
+ );
+ element.removeEventListener(
+ Events.TOUCH_DRAG,
+ this._dragCallback as EventListener
+ );
+ };
+
+ /**
+ * it is used to draw the length annotation in each
+ * request animation frame. It calculates the updated cached statistics if
+ * data is invalidated and cache it.
+ *
+ * @param enabledElement - The Cornerstone's enabledElement.
+ * @param svgDrawingHelper - The svgDrawingHelper providing the context for drawing.
+ */
+ renderAnnotation = (
+ enabledElement: Types.IEnabledElement,
+ svgDrawingHelper: SVGDrawingHelper
+ ): boolean => {
+ let renderStatus = false;
+ const { viewport } = enabledElement;
+ const { element } = viewport;
+
+ let annotations = getAnnotations(this.getToolName(), element);
+
+ // Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
+ if (!annotations?.length) {
+ return renderStatus;
+ }
+
+ annotations = this.filterInteractableAnnotationsForElement(
+ element,
+ annotations
+ );
+
+ const styleSpecifier: StyleSpecifier = {
+ toolGroupId: this.toolGroupId,
+ toolName: this.getToolName(),
+ viewportId: enabledElement.viewport.id,
+ };
+
+ // Draw SVG
+ for (let i = 0; i < annotations.length; i++) {
+ const annotation = annotations[i] as LabelAnnotation;
+ const { annotationUID, data } = annotation;
+ const point = data.handles.points[0];
+
+ styleSpecifier.annotationUID = annotationUID;
+
+ const canvasCoordinates = viewport.worldToCanvas(point);
+
+ renderStatus = true;
+
+ // If rendering engine has been destroyed while rendering
+ if (!viewport.getRenderingEngine()) {
+ console.warn('Rendering Engine has been destroyed');
+ return renderStatus;
+ }
+
+ if (!data.text) {
+ continue;
+ }
+
+ const options = this.getLinkedTextBoxStyle(styleSpecifier, annotation);
+
+ const textBoxUID = '1';
+ drawTextBoxSvg(
+ svgDrawingHelper,
+ annotationUID,
+ textBoxUID,
+ [data.text],
+ canvasCoordinates,
+ {
+ ...options,
+ padding: 0,
+ }
+ );
+ }
+
+ return renderStatus;
+ };
+
+ _isInsideVolume(index1, index2, dimensions) {
+ return (
+ csUtils.indexWithinDimensions(index1, dimensions) &&
+ csUtils.indexWithinDimensions(index2, dimensions)
+ );
+ }
+}
+
+function getTextCallback(doneChangingTextCallback) {
+ return doneChangingTextCallback(prompt('Enter your annotation:'));
+}
+
+function changeTextCallback(data, eventData, doneChangingTextCallback) {
+ return doneChangingTextCallback(prompt('Enter your annotation:'));
+}
+
+LabelTool.toolName = 'Label';
+export default LabelTool;
diff --git a/packages/tools/src/tools/index.ts b/packages/tools/src/tools/index.ts
index 4d8e26afb2..8add0eafee 100644
--- a/packages/tools/src/tools/index.ts
+++ b/packages/tools/src/tools/index.ts
@@ -20,6 +20,7 @@ import VolumeRotateTool from './VolumeRotateTool';
// Annotation tools
import BidirectionalTool from './annotation/BidirectionalTool';
+import LabelTool from './annotation/LabelTool';
import LengthTool from './annotation/LengthTool';
import HeightTool from './annotation/HeightTool';
import ProbeTool from './annotation/ProbeTool';
@@ -81,6 +82,7 @@ export {
OverlayGridTool,
SegmentationIntersectionTool,
BidirectionalTool,
+ LabelTool,
LengthTool,
HeightTool,
ProbeTool,
diff --git a/packages/tools/src/types/ToolSpecificAnnotationTypes.ts b/packages/tools/src/types/ToolSpecificAnnotationTypes.ts
index 30b3b1947e..c8a73f2d93 100644
--- a/packages/tools/src/types/ToolSpecificAnnotationTypes.ts
+++ b/packages/tools/src/types/ToolSpecificAnnotationTypes.ts
@@ -358,6 +358,15 @@ export interface ArrowAnnotation extends Annotation {
};
}
+export interface LabelAnnotation extends Annotation {
+ data: {
+ text: string;
+ handles: {
+ points: Types.Point3[];
+ };
+ };
+}
+
export interface AngleAnnotation extends Annotation {
data: {
handles: {