diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index dff737b6ca6d7..d2d78a8b4ab7e 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -2505,20 +2505,34 @@ class FreeTextAnnotationElement extends AnnotationElement { this.textContent = parameters.data.textContent; this.textPosition = parameters.data.textPosition; this.annotationEditorType = AnnotationEditorType.FREETEXT; + this.zoomLevel = 1; + this.initialFontSize = 16; + this.initialLineHeight = 1.2; + this.pageWidth = parameters.data.pageWidth || 600; } render() { this.container.classList.add("freeTextAnnotation"); + this.container.style.position = "relative"; + this.container.style.overflow = "visible"; // Allow content to grow outside the container if (this.textContent) { const content = document.createElement("div"); content.classList.add("annotationTextContent"); content.setAttribute("role", "comment"); - for (const line of this.textContent) { - const lineSpan = document.createElement("span"); - lineSpan.textContent = line; - content.append(lineSpan); - } + content.style.wordWrap = "break-word"; + content.style.whiteSpace = "pre-wrap"; + content.style.fontSize = `${this.initialFontSize}px`; + content.style.lineHeight = `${this.initialLineHeight}`; + content.style.width = "100%"; + content.style.height = "auto"; // Allow height to grow dynamically + content.style.boxSizing = "border-box"; + content.textContent = this.textContent; + + // Add event listener for input to dynamically resize the container + content.addEventListener("input", () => this._resizeToFitContent()); + + this.content = content; this.container.append(content); } @@ -2527,11 +2541,126 @@ class FreeTextAnnotationElement extends AnnotationElement { } this._editOnDoubleClick(); + this._addResizeHandles(); + + // Listen for zoom changes + window.addEventListener("zoom", this._onZoom.bind(this)); return this.container; } + + _resizeToFitContent() { + const contentRect = this.content.getBoundingClientRect(); + const newHeight = contentRect.height; + const newWidth = contentRect.width; + + this.container.style.width = `${newWidth}px`; + this.container.style.height = `${newHeight}px`; + } + + _addResizeHandles() { + const handleSize = 8; + + const createHandle = cursor => { + const handle = document.createElement("div"); + handle.style.width = `${handleSize}px`; + handle.style.height = `${handleSize}px`; + handle.style.background = "rgba(0, 0, 0, 0.5)"; + handle.style.position = "absolute"; + handle.style.cursor = cursor; + return handle; + }; + + // Create resize handles + const handles = { + se: createHandle("se-resize"), + }; + + handles.se.style.right = "0px"; + handles.se.style.bottom = "0px"; + + // Append handles to the container + this.container.append(handles.se); + + // Add event listeners for resizing + this._addResizeListeners(handles); + } + + _addResizeListeners(handles) { + const onResize = event => { + const rect = this.container.getBoundingClientRect(); + const newWidth = Math.max( + (event.clientX - rect.left) / this.zoomLevel, + 50 // minimum width + ); + const newHeight = Math.max( + (event.clientY - rect.top) / this.zoomLevel, + 50 // minimum height + ); + + // Ensure new dimensions do not exceed page bounds + const pageWidth = this.container.parentElement.clientWidth; + const pageHeight = this.container.parentElement.clientHeight; + const adjustedWidth = Math.min(newWidth, pageWidth - rect.left); + const adjustedHeight = Math.min(newHeight, pageHeight - rect.top); + + this.container.style.width = `${adjustedWidth}px`; + this.container.style.height = `${adjustedHeight}px`; + + // Update content dimensions + this.content.style.width = `${adjustedWidth}px`; + this.content.style.height = `${adjustedHeight}px`; + + // Update font size proportionally + const newFontSize = Math.max( + (this.initialFontSize * adjustedHeight) / (rect.height || adjustedHeight), + 12 // minimum font size + ); + this.content.style.fontSize = `${newFontSize}px`; + }; + + const onStopResize = () => { + window.removeEventListener("mousemove", onResize); + window.removeEventListener("mouseup", onStopResize); + }; + + handles.se.addEventListener("mousedown", () => { + window.addEventListener("mousemove", onResize); + window.addEventListener("mouseup", onStopResize); + }); + } + + _onZoom(event) { + this.zoomLevel = event.detail.zoom; + this._applyZoom(); + } + + _applyZoom() { + // Adjust the dimensions and positions according to the zoom level + const rect = this.data.rect; + const scale = this.zoomLevel; + + this.container.style.width = `${rect[2] - rect[0]}px`; + this.container.style.height = `${rect[3] - rect[1]}px`; + + this.container.style.transform = `scale(${scale})`; + this.container.style.transformOrigin = "top left"; + + // Ensure text stays within bounds + const scaledWidth = (rect[2] - rect[0]) * scale; + const scaledHeight = (rect[3] - rect[1]) * scale; + + this.content.style.width = `${scaledWidth}px`; + this.content.style.height = `${scaledHeight}px`; + } + + // Make sure to clean up event listeners + cleanup() { + window.removeEventListener("zoom", this._onZoom.bind(this)); + } } + class LineAnnotationElement extends AnnotationElement { #line = null; diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index fcd28f50b286d..16ecb410cdd5c 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -765,30 +765,33 @@ class FreeTextEditor extends AnnotationEditor { /** @inheritdoc */ static deserialize(data, parent, uiManager) { let initialData = null; + if (data instanceof FreeTextAnnotationElement) { const { data: { - defaultAppearanceData: { fontSize, fontColor }, - rect, - rotation, - id, + defaultAppearanceData: { fontSize, fontColor } = {}, + rect = [], + rotation = 0, + id = null, }, - textContent, - textPosition, + textContent = [], + textPosition = [], parent: { - page: { pageNumber }, - }, + page: { pageNumber = 1 }, + } = {}, } = data; + // textContent is supposed to be an array of strings containing each line // of text. However, it can be null or empty. if (!textContent || textContent.length === 0) { // Empty annotation. return null; } - initialData = data = { + + initialData = { annotationType: AnnotationEditorType.FREETEXT, - color: Array.from(fontColor), - fontSize, + color: Array.from(fontColor || [0, 0, 0]), // Default to black if fontColor is missing + fontSize: fontSize || 16, value: textContent.join("\n"), position: textPosition, pageIndex: pageNumber - 1, @@ -798,12 +801,21 @@ class FreeTextEditor extends AnnotationEditor { deleted: false, }; } - const editor = super.deserialize(data, parent, uiManager); - editor.#fontSize = data.fontSize; - editor.#color = Util.makeHexColor(...data.color); - editor.#content = FreeTextEditor.#deserializeContent(data.value); - editor.annotationElementId = data.id || null; - editor.#initialData = initialData; + + const editor = super.deserialize(initialData, parent, uiManager); + + if (editor) { + editor.#fontSize = initialData.fontSize; + editor.#color = Util.makeHexColor(...initialData.color); + editor.#content = FreeTextEditor.#deserializeContent(initialData.value); + editor.annotationElementId = initialData.id || null; + editor.#initialData = initialData; + + // Apply the necessary styles to ensure text wrapping and resizing + editor.container.style.overflowWrap = "break-word"; + editor.container.style.resize = "both"; + editor.container.style.boxSizing = "border-box"; + } return editor; } diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index eb13c7e46c05e..4807bc3f876db 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -152,6 +152,7 @@ max-width: 100%; max-height: 100%; border: var(--unfocus-outline); + overflow: hidden; &.draggable.selectedEditor { cursor: move; @@ -454,6 +455,11 @@ width: auto; height: auto; touch-action: none; + overflow-wrap: break-word; /* Ensure text wraps */ + resize: both; /* Allow resizing */ + box-sizing: border-box; /* Include padding and border in element's total width and height */ + position: relative; + border: 1px solid #000; /* Optional: to visually see the annotation box */ } .annotationEditorLayer .freeTextEditor .internal { @@ -461,10 +467,16 @@ border: none; inset: 0; overflow: visible; - white-space: nowrap; - font: 10px sans-serif; + white-space: pre-wrap; /* Ensure text wraps */ + font: calc(10px * var(--scale-factor)) sans-serif; /* Adjust font size according to scale factor */ line-height: var(--freetext-line-height); user-select: none; + width: 100%; + height: 100%; + box-sizing: border-box; + overflow: hidden; + white-space: pre-wrap; + padding: 0.5em; } .annotationEditorLayer .freeTextEditor .overlay { @@ -476,7 +488,7 @@ height: 100%; } -.annotationEditorLayer freeTextEditor .overlay.enabled { +.annotationEditorLayer .freeTextEditor .overlay.enabled { display: block; } @@ -510,77 +522,75 @@ .annotationEditorLayer .stampEditor { width: auto; height: auto; +} - canvas { - position: absolute; - width: 100%; - height: 100%; - margin: 0; - top: 0; - left: 0; - } +.annotationEditorLayer .stampEditor canvas { + position: absolute; + width: 100%; + height: 100%; + margin: 0; + top: 0; + left: 0; } -.annotationEditorLayer { - :is(.freeTextEditor, .inkEditor, .stampEditor) { - & > .resizers { - position: absolute; - inset: 0; - &.hidden { - display: none; - } +.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) > .resizers { + position: absolute; + inset: 0; - & > .resizer { - width: var(--resizer-size); - height: var(--resizer-size); - background: content-box var(--resizer-bg-color); - border: var(--focus-outline-around); - border-radius: 2px; - position: absolute; - - &.topLeft { - top: var(--resizer-shift); - left: var(--resizer-shift); - } + &.hidden { + display: none; + } - &.topMiddle { - top: var(--resizer-shift); - left: calc(50% + var(--resizer-shift)); - } + > .resizer { + width: var(--resizer-size); + height: var(--resizer-size); + background: content-box var(--resizer-bg-color); + border: var(--focus-outline-around); + border-radius: 2px; + position: absolute; - &.topRight { - top: var(--resizer-shift); - right: var(--resizer-shift); - } + &.topLeft { + top: var(--resizer-shift); + left: var(--resizer-shift); + } - &.middleRight { - top: calc(50% + var(--resizer-shift)); - right: var(--resizer-shift); - } + &.topMiddle { + top: var(--resizer-shift); + left: calc(50% + var(--resizer-shift)); + } - &.bottomRight { - bottom: var(--resizer-shift); - right: var(--resizer-shift); - } + &.topRight { + top: var(--resizer-shift); + right: var(--resizer-shift); + } - &.bottomMiddle { - bottom: var(--resizer-shift); - left: calc(50% + var(--resizer-shift)); - } + &.middleRight { + top: calc(50% + var(--resizer-shift)); + right: var(--resizer-shift); + } - &.bottomLeft { - bottom: var(--resizer-shift); - left: var(--resizer-shift); - } + &.bottomRight { + bottom: var(--resizer-shift); + right: var(--resizer-shift); + } - &.middleLeft { - top: calc(50% + var(--resizer-shift)); - left: var(--resizer-shift); - } - } + &.bottomMiddle { + bottom: var(--resizer-shift); + left: calc(50% + var(--resizer-shift)); + } + + &.bottomLeft { + bottom: var(--resizer-shift); + left: var(--resizer-shift); + } + + &.middleLeft { + top: calc(50% + var(--resizer-shift)); + left: var(--resizer-shift); } } +} &[data-main-rotation="0"] :is([data-editor-rotation="0"], [data-editor-rotation="180"]),