From 5a94d4679fa13f504a2aeeb02a53fc806118dcae Mon Sep 17 00:00:00 2001 From: shivansh chourasia Date: Wed, 17 Jul 2024 17:45:27 +0530 Subject: [PATCH 1/2] Add wrapping and resizing for Freetext annotations --- src/display/annotation_layer.js | 103 +++++++++++++++++++++-- web/annotation_editor_layer_builder.css | 106 ++++++++++++------------ 2 files changed, 152 insertions(+), 57 deletions(-) diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index dff737b6ca6d7..c787576391397 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -2505,20 +2505,31 @@ 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; // Default page width if not provided } render() { this.container.classList.add("freeTextAnnotation"); + this.container.style.position = "relative"; + this.container.style.overflow = "hidden"; 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 = "100%"; + content.style.boxSizing = "border-box"; + content.textContent = this.textContent; + + this.content = content; this.container.append(content); } @@ -2527,9 +2538,91 @@ class FreeTextAnnotationElement extends AnnotationElement { } this._editOnDoubleClick(); + this._addResizeHandles(); + + // Listen for zoom changes + window.addEventListener("zoom", this._onZoom.bind(this)); return this.container; } + + _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.min( + (event.clientX - rect.left) / this.zoomLevel, + this.pageWidth + ); + const newHeight = (event.clientY - rect.top) / this.zoomLevel; + this.container.style.width = `${newWidth}px`; + this.container.style.height = `${newHeight}px`; + this.content.style.width = `${newWidth}px`; + + // Update font size proportionally + const newFontSize = (this.initialFontSize * newHeight) / (rect.height || newHeight); + 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"; + } + + // Make sure to clean up event listeners + cleanup() { + window.removeEventListener("zoom", this._onZoom.bind(this)); + } } class LineAnnotationElement extends AnnotationElement { diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index eb13c7e46c05e..be3db0bbce4d8 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -454,6 +454,9 @@ 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 */ } .annotationEditorLayer .freeTextEditor .internal { @@ -461,12 +464,14 @@ border: none; inset: 0; overflow: visible; - white-space: nowrap; + white-space: pre-wrap; font: 10px sans-serif; line-height: var(--freetext-line-height); user-select: none; + width: 100%; + height: 100%; + box-sizing: border-box; } - .annotationEditorLayer .freeTextEditor .overlay { position: absolute; display: none; @@ -476,7 +481,7 @@ height: 100%; } -.annotationEditorLayer freeTextEditor .overlay.enabled { +.annotationEditorLayer .freeTextEditor .overlay.enabled { display: block; } @@ -521,66 +526,63 @@ } } -.annotationEditorLayer { - :is(.freeTextEditor, .inkEditor, .stampEditor) { - & > .resizers { - position: absolute; - inset: 0; +.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) > .resizers { + position: absolute; + inset: 0; - &.hidden { - display: none; - } + &.hidden { + display: none; + } - & > .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); - } + > .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; - &.topMiddle { - top: var(--resizer-shift); - left: calc(50% + var(--resizer-shift)); - } + &.topLeft { + top: var(--resizer-shift); + left: var(--resizer-shift); + } - &.topRight { - top: var(--resizer-shift); - right: var(--resizer-shift); - } + &.topMiddle { + top: var(--resizer-shift); + left: calc(50% + var(--resizer-shift)); + } - &.middleRight { - top: calc(50% + var(--resizer-shift)); - right: var(--resizer-shift); - } + &.topRight { + top: var(--resizer-shift); + right: var(--resizer-shift); + } - &.bottomRight { - bottom: var(--resizer-shift); - right: var(--resizer-shift); - } + &.middleRight { + top: calc(50% + var(--resizer-shift)); + right: var(--resizer-shift); + } - &.bottomMiddle { - bottom: var(--resizer-shift); - left: calc(50% + var(--resizer-shift)); - } + &.bottomRight { + bottom: var(--resizer-shift); + right: var(--resizer-shift); + } - &.bottomLeft { - bottom: var(--resizer-shift); - left: var(--resizer-shift); - } + &.bottomMiddle { + bottom: var(--resizer-shift); + left: calc(50% + var(--resizer-shift)); + } - &.middleLeft { - top: calc(50% + var(--resizer-shift)); - left: 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"]), From 6e9017726dd5dbff0051ac24fd318615ebdffe6b Mon Sep 17 00:00:00 2001 From: shivansh chourasia Date: Sat, 20 Jul 2024 21:49:12 +0530 Subject: [PATCH 2/2] Fix: saving the PDF and re-opening it, the annotation text still wraps correctly --- src/display/annotation_layer.js | 58 ++++++++++++++++++++----- src/display/editor/freetext.js | 46 ++++++++++++-------- web/annotation_editor_layer_builder.css | 30 ++++++++----- 3 files changed, 95 insertions(+), 39 deletions(-) diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index c787576391397..d2d78a8b4ab7e 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -2508,27 +2508,30 @@ class FreeTextAnnotationElement extends AnnotationElement { this.zoomLevel = 1; this.initialFontSize = 16; this.initialLineHeight = 1.2; - this.pageWidth = parameters.data.pageWidth || 600; // Default page width if not provided + this.pageWidth = parameters.data.pageWidth || 600; } render() { this.container.classList.add("freeTextAnnotation"); this.container.style.position = "relative"; - this.container.style.overflow = "hidden"; + 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"); content.style.wordWrap = "break-word"; - content.style.whiteSpace = "pre-wrap"; + content.style.whiteSpace = "pre-wrap"; content.style.fontSize = `${this.initialFontSize}px`; content.style.lineHeight = `${this.initialLineHeight}`; content.style.width = "100%"; - content.style.height = "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); } @@ -2546,6 +2549,15 @@ class FreeTextAnnotationElement extends AnnotationElement { 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; @@ -2577,17 +2589,33 @@ class FreeTextAnnotationElement extends AnnotationElement { _addResizeListeners(handles) { const onResize = event => { const rect = this.container.getBoundingClientRect(); - const newWidth = Math.min( + const newWidth = Math.max( (event.clientX - rect.left) / this.zoomLevel, - this.pageWidth + 50 // minimum width + ); + const newHeight = Math.max( + (event.clientY - rect.top) / this.zoomLevel, + 50 // minimum height ); - const newHeight = (event.clientY - rect.top) / this.zoomLevel; - this.container.style.width = `${newWidth}px`; - this.container.style.height = `${newHeight}px`; - this.content.style.width = `${newWidth}px`; + + // 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 = (this.initialFontSize * newHeight) / (rect.height || newHeight); + const newFontSize = Math.max( + (this.initialFontSize * adjustedHeight) / (rect.height || adjustedHeight), + 12 // minimum font size + ); this.content.style.fontSize = `${newFontSize}px`; }; @@ -2617,6 +2645,13 @@ class FreeTextAnnotationElement extends AnnotationElement { 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 @@ -2625,6 +2660,7 @@ class FreeTextAnnotationElement extends AnnotationElement { } } + 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 be3db0bbce4d8..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; @@ -457,6 +458,8 @@ 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 { @@ -464,14 +467,18 @@ border: none; inset: 0; overflow: visible; - white-space: pre-wrap; - 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; + box-sizing: border-box; + overflow: hidden; + white-space: pre-wrap; + padding: 0.5em; } + .annotationEditorLayer .freeTextEditor .overlay { position: absolute; display: none; @@ -515,17 +522,18 @@ .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;