diff --git a/src/display/editor/toolbar.js b/src/display/editor/toolbar.js index 563ad965e01e2..36e286210e70b 100644 --- a/src/display/editor/toolbar.js +++ b/src/display/editor/toolbar.js @@ -341,8 +341,47 @@ class FloatingToolbar { const [x, y] = this.#getLastPoint(boxes, isLTR); const { style } = (this.#toolbar ||= this.#render()); parent.append(this.#toolbar); - style.insetInlineEnd = `${100 * x}%`; - style.top = `calc(${100 * y}% + var(--editor-toolbar-vert-offset))`; + + const parentRect = parent.getBoundingClientRect(); + const parentHeight = parentRect.height; + const parentWidth = parentRect.width; + + const toolbarRect = this.#toolbar.getBoundingClientRect(); + const toolbarHeight = toolbarRect.height; + const toolbarWidth = toolbarRect.width; + const offset = parseFloat( + getComputedStyle(parent).getPropertyValue("--editor-toolbar-vert-offset") + ); + + // Y-axis positioning: check if toolbar fits below the text + const textBottomY = parentHeight * y; + const toolbarBottomY = textBottomY + offset + toolbarHeight; + + if (toolbarBottomY <= parentHeight) { + // Toolbar fits below: position toolbar below the text + style.top = `calc(${100 * y}% + var(--editor-toolbar-vert-offset))`; + } else { + // Toolbar doesn't fit below: position toolbar above the text. + // The first box will always have the lowest Y because reading + // is from top to bottom and boxes[0] is the topmost text. + const lowestY = boxes[0].y * 100; + style.top = `max(0px, calc(${lowestY}% - ${toolbarHeight}px - var(--editor-toolbar-vert-offset)))`; + } + + // X-axis positioning: check if toolbar fits on the right side + const textRightX = parentWidth * (1 - x); + const toolbarLeftX = textRightX - toolbarWidth; + + if (toolbarLeftX >= 0) { + // Toolbar fits on the right: align right with the text + style.insetInlineEnd = `${100 * x}%`; + style.insetInlineStart = ""; + } else { + // Toolbar doesn't fit on the right: align left with the start of the text + const minX = boxes[0].x * 100; + style.insetInlineStart = `${minX}%`; + style.insetInlineEnd = ""; + } } hide() { diff --git a/test/integration/floating_toolbar_spec.mjs b/test/integration/floating_toolbar_spec.mjs new file mode 100644 index 0000000000000..c20822febc0b5 --- /dev/null +++ b/test/integration/floating_toolbar_spec.mjs @@ -0,0 +1,137 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + closePages, + getRect, + getSpanRectFromText, + loadAndWait, +} from "./test_utils.mjs"; + +async function selectTextAndShowToolbar(page, pageNumber, text) { + const rect = await getSpanRectFromText(page, pageNumber, text); + const x = rect.x + rect.width * 0.5; + const y = rect.y + rect.height * 0.5; + await page.mouse.click(x, y, { count: 2, delay: 400 }); + await page.waitForSelector(".editToolbar", { timeout: 2000 }); + return rect; +} + +function expectToolbarWithinPage(browserName, toolbarBounds, pageBounds) { + expect(toolbarBounds.x) + .withContext( + `In ${browserName}: Toolbar should not be cut off at the left of the page` + ) + .toBeGreaterThanOrEqual(pageBounds.x); + + expect(toolbarBounds.x + toolbarBounds.width) + .withContext( + `In ${browserName}: Toolbar should not be cut off at the right of the page` + ) + .toBeLessThanOrEqual(pageBounds.x + pageBounds.width); + + expect(toolbarBounds.y) + .withContext( + `In ${browserName}: Toolbar should not be cut off at the top of the page` + ) + .toBeGreaterThanOrEqual(pageBounds.y); + + expect(toolbarBounds.y + toolbarBounds.height) + .withContext( + `In ${browserName}: Toolbar should not be cut off at the bottom of the page` + ) + .toBeLessThanOrEqual(pageBounds.y + pageBounds.height); +} + +function expectToolbarBelowText(browserName, toolbarBounds, textRect) { + expect(toolbarBounds.y) + .withContext(`In ${browserName}: Toolbar should be shown below the text`) + .toBeGreaterThanOrEqual(textRect.y + textRect.height); +} + +function expectToolbarAboveText(browserName, toolbarBounds, textRect) { + expect(toolbarBounds.y + toolbarBounds.height) + .withContext(`In ${browserName}: Toolbar should be shown above the text`) + .toBeLessThanOrEqual(textRect.y); +} + +describe("Floating Toolbar Positioning", () => { + describe("Toolbar positioning with text selection", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "floating-toolbar-positioning.pdf", + ".textLayer" + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must show the toolbar below the text and inside the page when selecting BR", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const textRect = await selectTextAndShowToolbar(page, 1, "BR"); + const toolbarBounds = await getRect(page, ".editToolbar"); + const pageBounds = await getRect(page, '.page[data-page-number="1"]'); + + expectToolbarWithinPage(browserName, toolbarBounds, pageBounds); + expectToolbarBelowText(browserName, toolbarBounds, textRect); + }) + ); + }); + + it("must show the toolbar below the text and inside the page when selecting BL", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const textRect = await selectTextAndShowToolbar(page, 1, "BL"); + const toolbarBounds = await getRect(page, ".editToolbar"); + const pageBounds = await getRect(page, '.page[data-page-number="1"]'); + + expectToolbarWithinPage(browserName, toolbarBounds, pageBounds); + expectToolbarBelowText(browserName, toolbarBounds, textRect); + }) + ); + }); + + it("must show the toolbar above the text and inside the page when selecting TR", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const textRect = await selectTextAndShowToolbar(page, 1, "TR"); + const toolbarBounds = await getRect(page, ".editToolbar"); + const pageBounds = await getRect(page, '.page[data-page-number="1"]'); + + expectToolbarWithinPage(browserName, toolbarBounds, pageBounds); + expectToolbarAboveText(browserName, toolbarBounds, textRect); + }) + ); + }); + + it("must show the toolbar above the text and inside the page when selecting TL", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const textRect = await selectTextAndShowToolbar(page, 1, "TL"); + const toolbarBounds = await getRect(page, ".editToolbar"); + const pageBounds = await getRect(page, '.page[data-page-number="1"]'); + + expectToolbarWithinPage(browserName, toolbarBounds, pageBounds); + expectToolbarAboveText(browserName, toolbarBounds, textRect); + }) + ); + }); + }); +}); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index b617ab60badf8..196c3b1b711ff 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -547,6 +547,7 @@ !issue11549_reduced.pdf !issue8097_reduced.pdf !issue15262.pdf +!floating-toolbar-positioning.pdf !issue17904.pdf !bug1743245.pdf !quadpoints.pdf diff --git a/test/pdfs/floating-toolbar-positioning.pdf b/test/pdfs/floating-toolbar-positioning.pdf new file mode 100644 index 0000000000000..d7ae60da7fa9d Binary files /dev/null and b/test/pdfs/floating-toolbar-positioning.pdf differ diff --git a/test/test_manifest.json b/test/test_manifest.json index 08b6dfdbf7ec8..417c656a05ee0 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -13501,6 +13501,13 @@ "type": "eq", "useWasm": false }, + { + "id": "floating-toolbar-positioning", + "file": "pdfs/floating-toolbar-positioning.pdf", + "md5": "bef4ff1ef3b51b1dffb3e84e60d1c900", + "rounds": 1, + "type": "eq" + }, { "id": "jbig2_bitmap_initially_unknown_size", "file": "pdfs/bitmap-initially-unknown-size.pdf",