diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 3a2815a31..b1b9a0201 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -381,6 +381,10 @@ class AnnotationEditorLayer { } moveEditorInDOM(editor) { + if (!editor.isAttachedToDOM) { + return; + } + const { activeElement } = document; if (editor.div.contains(activeElement)) { // When the div is moved in the DOM the focus can move somewhere else, @@ -425,9 +429,7 @@ class AnnotationEditorLayer { * @param {AnnotationEditor} editor */ addUndoableEditor(editor) { - const cmd = () => { - this.addOrRebuild(editor); - }; + const cmd = () => editor._uiManager.rebuild(editor); const undo = () => { editor.remove(); }; diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 6229ddae7..43b6e55a2 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -42,7 +42,7 @@ class AnnotationEditor { #boundFocusout = this.focusout.bind(this); - #hasBeenSelected = false; + #hasBeenClicked = false; #isEditing = false; @@ -195,10 +195,10 @@ class AnnotationEditor { if (!this._focusEventsAllowed) { return; } - if (!this.#hasBeenSelected) { + if (!this.#hasBeenClicked) { this.parent.setSelected(this); } else { - this.#hasBeenSelected = false; + this.#hasBeenClicked = false; } } @@ -293,7 +293,27 @@ class AnnotationEditor { */ translateInPage(x, y) { this.#translate(this.pageDimensions, x, y); - this.parent.moveEditorInDOM(this); + this.moveInDOM(); + this.div.scrollIntoView({ block: "nearest" }); + } + + drag(tx, ty) { + const [parentWidth, parentHeight] = this.parentDimensions; + this.x += tx / parentWidth; + this.y += ty / parentHeight; + if (this.x < 0 || this.x > 1 || this.y < 0 || this.y > 1) { + // The element will be outside of its parent so change the parent. + const { x, y } = this.div.getBoundingClientRect(); + if (this.parent.findNewParent(this, x, y)) { + this.x -= Math.floor(this.x); + this.y -= Math.floor(this.y); + } + } + + // The editor can be moved wherever the user wants, so we don't need to fix + // the position: it'll be done when the user will release the mouse button. + this.div.style.left = `${(100 * this.x).toFixed(2)}%`; + this.div.style.top = `${(100 * this.y).toFixed(2)}%`; this.div.scrollIntoView({ block: "nearest" }); } @@ -516,7 +536,7 @@ class AnnotationEditor { const [parentWidth, parentHeight] = this.parentDimensions; this.setDims(parentWidth * newWidth, parentHeight * newHeight); this.fixAndSetPosition(); - this.parent.moveEditorInDOM(this); + this.moveInDOM(); }, undo: () => { this.width = savedWidth; @@ -526,7 +546,7 @@ class AnnotationEditor { const [parentWidth, parentHeight] = this.parentDimensions; this.setDims(parentWidth * savedWidth, parentHeight * savedHeight); this.fixAndSetPosition(); - this.parent.moveEditorInDOM(this); + this.moveInDOM(); }, mustExec: true, }); @@ -712,17 +732,7 @@ class AnnotationEditor { return; } - if ( - (event.ctrlKey && !isMac) || - event.shiftKey || - (event.metaKey && isMac) - ) { - this.parent.toggleSelected(this); - } else { - this.parent.setSelected(this); - } - - this.#hasBeenSelected = true; + this.#hasBeenClicked = true; this.#setUpDragSession(event); } @@ -732,74 +742,47 @@ class AnnotationEditor { return; } - // Avoid to have spurious text selection in the text layer when dragging. - this._uiManager.disableUserSelect(true); + const isSelected = this._uiManager.isSelected(this); + this._uiManager.setUpDragSession(); - const savedParent = this.parent; - const savedX = this.x; - const savedY = this.y; - - const pointerMoveOptions = { passive: true, capture: true }; - const pointerMoveCallback = e => { - const [parentWidth, parentHeight] = this.parentDimensions; - const [tx, ty] = this.screenToPageTranslation(e.movementX, e.movementY); - this.x += tx / parentWidth; - this.y += ty / parentHeight; - if (this.x < 0 || this.x > 1 || this.y < 0 || this.y > 1) { - // The element will be outside of its parent so change the parent. - const { x, y } = this.div.getBoundingClientRect(); - if (this.parent.findNewParent(this, x, y)) { - this.x -= Math.floor(this.x); - this.y -= Math.floor(this.y); - } - } - - this.div.style.left = `${(100 * this.x).toFixed(2)}%`; - this.div.style.top = `${(100 * this.y).toFixed(2)}%`; - this.div.scrollIntoView({ block: "nearest" }); - }; - window.addEventListener( - "pointermove", - pointerMoveCallback, - pointerMoveOptions - ); - - const pointerUpCallback = () => { - this._uiManager.disableUserSelect(false); - window.removeEventListener("pointerup", pointerUpCallback); - window.removeEventListener("blur", pointerUpCallback); - window.removeEventListener( + let pointerMoveOptions, pointerMoveCallback; + if (isSelected) { + pointerMoveOptions = { passive: true, capture: true }; + pointerMoveCallback = e => { + const [tx, ty] = this.screenToPageTranslation(e.movementX, e.movementY); + this._uiManager.dragSelectedEditors(tx, ty); + }; + window.addEventListener( "pointermove", pointerMoveCallback, pointerMoveOptions ); - const newParent = this.parent; - const newX = this.x; - const newY = this.y; - if (newParent === savedParent && newX === savedX && newY === savedY) { - return; + } + + const pointerUpCallback = () => { + window.removeEventListener("pointerup", pointerUpCallback); + window.removeEventListener("blur", pointerUpCallback); + if (isSelected) { + window.removeEventListener( + "pointermove", + pointerMoveCallback, + pointerMoveOptions + ); } - this.addCommands({ - cmd: () => { - newParent.changeParent(this); - this.x = newX; - this.y = newY; - this.fixAndSetPosition(); - newParent.moveEditorInDOM(this); - }, - undo: () => { - savedParent.changeParent(this); - this.x = savedX; - this.y = savedY; - this.fixAndSetPosition(); - savedParent.moveEditorInDOM(this); - }, - mustExec: false, - }); - - this.fixAndSetPosition(); - this.parent.moveEditorInDOM(this); + this.#hasBeenClicked = false; + if (!this._uiManager.endDragSession()) { + const { isMac } = FeatureTest.platform; + if ( + (event.ctrlKey && !isMac) || + event.shiftKey || + (event.metaKey && isMac) + ) { + this.parent.toggleSelected(this); + } else { + this.parent.setSelected(this); + } + } }; window.addEventListener("pointerup", pointerUpCallback); // If the user is using alt+tab during the dragging session, the pointerup @@ -808,6 +791,18 @@ class AnnotationEditor { window.addEventListener("blur", pointerUpCallback); } + moveInDOM() { + this.parent.moveEditorInDOM(this); + } + + _setParentAndPosition(parent, x, y) { + parent.changeParent(this); + this.x = x; + this.y = y; + this.fixAndSetPosition(); + this.moveInDOM(); + } + /** * Convert the current rect into a page one. */ diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index d3dac2092..08dd5f362 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -281,6 +281,9 @@ class FreeTextEditor extends AnnotationEditor { /** @inheritdoc */ rebuild() { + if (!this.parent) { + return; + } super.rebuild(); if (this.div === null) { return; @@ -447,7 +450,7 @@ class FreeTextEditor extends AnnotationEditor { return; } this.#setContent(); - this.rebuild(); + this._uiManager.rebuild(this); this.#setEditorDimensions(); }; this.addCommands({ diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index 28cc4776a..cd73b5309 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -226,6 +226,9 @@ class InkEditor extends AnnotationEditor { /** @inheritdoc */ rebuild() { + if (!this.parent) { + return; + } super.rebuild(); if (this.div === null) { return; @@ -626,7 +629,7 @@ class InkEditor extends AnnotationEditor { // When commiting, the position of this editor is changed, hence we must // move it to the right position in the DOM. - this.parent.moveEditorInDOM(this); + this.moveInDOM(); this.div.focus({ preventScroll: true /* See issue #15744 */, }); diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js index 567bd8abf..a9a55eaa2 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -161,6 +161,14 @@ class StampEditor extends AnnotationEditor { /** @inheritdoc */ rebuild() { + if (!this.parent) { + // It's possible to have to rebuild an editor which is not on a visible + // page. + if (this.#bitmapId) { + this.#getBitmap(); + } + return; + } super.rebuild(); if (this.div === null) { return; diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index ede98728b..1dce7f0f5 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -532,6 +532,8 @@ class AnnotationEditorUIManager { #deletedAnnotationsElementIds = new Set(); + #draggingEditors = null; + #editorTypes = null; #editorsToRescale = new Set(); @@ -1059,6 +1061,10 @@ class AnnotationEditorUIManager { return this.#allLayers.get(this.#currentPageIndex); } + getLayer(pageIndex) { + return this.#allLayers.get(pageIndex); + } + get currentPageIndex() { return this.#currentPageIndex; } @@ -1173,7 +1179,7 @@ class AnnotationEditorUIManager { } /** - * Get all the editors belonging to a give page. + * Get all the editors belonging to a given page. * @param {number} pageIndex * @returns {Array} */ @@ -1520,6 +1526,123 @@ class AnnotationEditorUIManager { } } + /** + * Set up the drag session for moving the selected editors. + */ + setUpDragSession() { + if (!this.hasSelection) { + return; + } + // Avoid to have spurious text selection in the text layer when dragging. + this.disableUserSelect(true); + this.#draggingEditors = new Map(); + for (const editor of this.#selectedEditors) { + this.#draggingEditors.set(editor, { + savedX: editor.x, + savedY: editor.y, + savedPageIndex: editor.parent.pageIndex, + newX: 0, + newY: 0, + newPageIndex: -1, + }); + } + } + + /** + * Ends the drag session. + * @returns {boolean} true if at least one editor has been moved. + */ + endDragSession() { + if (!this.#draggingEditors) { + return false; + } + this.disableUserSelect(false); + const map = this.#draggingEditors; + this.#draggingEditors = null; + let mustBeAddedInUndoStack = false; + + for (const [{ x, y, parent }, value] of map) { + value.newX = x; + value.newY = y; + value.newPageIndex = parent.pageIndex; + mustBeAddedInUndoStack ||= + x !== value.savedX || + y !== value.savedY || + parent.pageIndex !== value.savedPageIndex; + } + + if (!mustBeAddedInUndoStack) { + return false; + } + + const move = (editor, x, y, pageIndex) => { + if (this.#allEditors.has(editor.id)) { + // The editor can be undone/redone on a page which is not visible (and + // which potentially has no annotation editor layer), hence we need to + // use the pageIndex instead of the parent. + const parent = this.#allLayers.get(pageIndex); + if (parent) { + editor._setParentAndPosition(parent, x, y); + } else { + editor.pageIndex = pageIndex; + editor.x = x; + editor.y = y; + } + } + }; + + this.addCommands({ + cmd: () => { + for (const [editor, { newX, newY, newPageIndex }] of map) { + move(editor, newX, newY, newPageIndex); + } + }, + undo: () => { + for (const [editor, { savedX, savedY, savedPageIndex }] of map) { + move(editor, savedX, savedY, savedPageIndex); + } + }, + mustExec: true, + }); + + return true; + } + + /** + * Drag the set of selected editors. + * @param {number} tx + * @param {number} ty + */ + dragSelectedEditors(tx, ty) { + if (!this.#draggingEditors) { + return; + } + for (const editor of this.#draggingEditors.keys()) { + editor.drag(tx, ty); + } + } + + /** + * Rebuild the editor (usually on undo/redo actions) on a potentially + * non-rendered page. + * @param {AnnotationEditor} editor + */ + rebuild(editor) { + if (editor.parent === null) { + const parent = this.getLayer(editor.pageIndex); + if (parent) { + parent.changeParent(editor); + parent.addOrRebuild(editor); + } else { + this.addEditor(editor); + this.addToAnnotationStorage(editor); + editor.rebuild(); + } + } else { + editor.parent.addOrRebuild(editor); + } + } + /** * Is the current editor the one passed as argument? * @param {AnnotationEditor} editor diff --git a/test/integration/freetext_editor_spec.js b/test/integration/freetext_editor_spec.js index a2366d10f..45707987b 100644 --- a/test/integration/freetext_editor_spec.js +++ b/test/integration/freetext_editor_spec.js @@ -921,6 +921,9 @@ describe("FreeText Editor", () => { return { x, y, width, height }; }); + // Select the annotation we want to move. + await page.mouse.click(editorRect.x + 2, editorRect.y + 2); + await dragAndDropAnnotation( page, editorRect.x + editorRect.width / 2, @@ -2193,4 +2196,73 @@ describe("FreeText Editor", () => { ); }); }); + + describe("Move several FreeTexts", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must move several annotations", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.click("#editorFreeText"); + + const rect = await page.$eval(".annotationEditorLayer", el => { + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + const allPositions = []; + + for (let i = 0; i < 10; i++) { + await page.mouse.click(rect.x + 10 + 30 * i, rect.y + 100 + 5 * i); + await page.waitForTimeout(10); + await page.type( + `${getEditorSelector(i)} .internal`, + String.fromCharCode(65 + i) + ); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForTimeout(10); + + allPositions.push( + await page.$eval(getEditorSelector(i), el => { + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }) + ); + } + + await page.keyboard.down("Control"); + await page.keyboard.press("a"); + await page.keyboard.up("Control"); + + await page.waitForTimeout(10); + await dragAndDropAnnotation(page, rect.x + 161, rect.y + 126, 39, 74); + await page.waitForTimeout(10); + + for (let i = 0; i < 10; i++) { + const pos = await page.$eval(getEditorSelector(i), el => { + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + const oldPos = allPositions[i]; + expect(Math.round(pos.x)) + .withContext(`In ${browserName}`) + .toEqual(Math.round(oldPos.x + 39)); + expect(Math.round(pos.y)) + .withContext(`In ${browserName}`) + .toEqual(Math.round(oldPos.y + 74)); + } + }) + ); + }); + }); }); diff --git a/test/integration/test_utils.js b/test/integration/test_utils.js index 1945b6cf5..08fc01925 100644 --- a/test/integration/test_utils.js +++ b/test/integration/test_utils.js @@ -198,6 +198,7 @@ exports.serializeBitmapDimensions = serializeBitmapDimensions; async function dragAndDropAnnotation(page, startX, startY, tX, tY) { await page.mouse.move(startX, startY); await page.mouse.down(); + await page.waitForTimeout(10); await page.mouse.move(startX + tX, startY + tY); await page.mouse.up(); } diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 95eba5b02..e06d461db 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -81,7 +81,7 @@ } .annotationEditorLayer - :is(.freeTextEditor, .inkEditor, .stampEditor).draggable { + :is(.freeTextEditor, .inkEditor, .stampEditor).draggable.selectedEditor { cursor: move; }