From 34035bc22fb22253adcbbc2aafd87e314b5fd831 Mon Sep 17 00:00:00 2001 From: TW Date: Tue, 1 Sep 2026 21:05:13 +0800 Subject: [PATCH 1/2] feat: add automatic selection button avoidance - Add an automatic button position option and make it the default for new settings. - Detect visible page overlays across four deterministic candidate positions. - Recheck placement while delayed page popups appear and keep the button aligned on scroll. - Localize the automatic option and cover collision behavior with unit tests. --- .../src/common/scripts/settings.js | 2 +- .../src/content/select/select.js | 2 +- .../src/content/select/select_button.js | 42 ++-- .../select/select_button_auto_avoid.js | 63 ++++++ .../src/content/select/select_helpers.js | 193 ++++++++++++++++++ .../EdgeTranslate/src/options/options.html | 5 + .../static/_locales/en/messages.json | 4 + .../static/_locales/ja/messages.json | 4 + .../static/_locales/ru/messages.json | 4 + .../static/_locales/zh_CN/messages.json | 4 + .../static/_locales/zh_TW/messages.json | 4 + .../content/select/select_helpers.test.js | 111 ++++++++++ 12 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 packages/EdgeTranslate/src/content/select/select_button_auto_avoid.js create mode 100644 packages/EdgeTranslate/test/unit/content/select/select_helpers.test.js diff --git a/packages/EdgeTranslate/src/common/scripts/settings.js b/packages/EdgeTranslate/src/common/scripts/settings.js index 5e6793c2..45929868 100644 --- a/packages/EdgeTranslate/src/common/scripts/settings.js +++ b/packages/EdgeTranslate/src/common/scripts/settings.js @@ -18,7 +18,7 @@ const DEFAULT_SETTINGS = { RTL: false, FoldLongContent: true, AutoClosePanelOnPageScroll: true, - SelectTranslatePosition: "TopRight", + SelectTranslatePosition: "AutoAvoid", }, // Default settings of source language and target language languageSetting: { sl: "auto", tl: BROWSER_LANGUAGES_MAP[chrome.i18n.getUILanguage()] }, diff --git a/packages/EdgeTranslate/src/content/select/select.js b/packages/EdgeTranslate/src/content/select/select.js index 7b13974c..b00e524f 100644 --- a/packages/EdgeTranslate/src/content/select/select.js +++ b/packages/EdgeTranslate/src/content/select/select.js @@ -46,7 +46,7 @@ function initSelectTranslate() { function createSelectState() { return { - buttonPositionSetting: "TopRight", + buttonPositionSetting: "AutoAvoid", buttonSelection: null, channel: new Channel(), hasButtonShown: false, diff --git a/packages/EdgeTranslate/src/content/select/select_button.js b/packages/EdgeTranslate/src/content/select/select_button.js index 315adb28..c2377d57 100644 --- a/packages/EdgeTranslate/src/content/select/select_button.js +++ b/packages/EdgeTranslate/src/content/select/select_button.js @@ -6,6 +6,7 @@ import { getInnerParent, getSelection, } from "./select_helpers.js"; +import { cancelAutoAvoidRechecks, scheduleAutoAvoidRechecks } from "./select_button_auto_avoid.js"; const BUTTON_HOST_ID = "edge-translate-button-host"; const BUTTON_ID = "edge-translate-button"; @@ -39,31 +40,32 @@ export function initializeButtonContainer(state, onMouseDown) { export function showButton(state, event) { state.buttonSelection = getSelection(); + state.buttonAnchor = { x: event.x, y: event.y }; showButtonLayer(state); - const position = getButtonPosition( - state.buttonPositionSetting, - state.translationButtonContainer, - event - ); - state.translationButtonContainer.style.top = `${position.top}px`; - state.translationButtonContainer.style.left = `${position.left}px`; - state.originScrollX = state.scrollingElement[state.scrollPropertyX]; - state.originScrollY = state.scrollingElement[state.scrollPropertyY]; - state.originPositionX = position.left; - state.originPositionY = position.top; + positionButton(state); state.hasButtonShown = true; + scheduleAutoAvoidRechecks(state, () => positionButton(state)); } export function scrollHandler(state) { if (!state.hasButtonShown) return; const distanceX = state.originScrollX - state.scrollingElement[state.scrollPropertyX]; const distanceY = state.originScrollY - state.scrollingElement[state.scrollPropertyY]; - state.translationButtonContainer.style.left = `${state.originPositionX + distanceX}px`; - state.translationButtonContainer.style.top = `${state.originPositionY + distanceY}px`; + const left = state.originPositionX + distanceX; + const top = state.originPositionY + distanceY; + state.translationButtonContainer.style.left = `${left}px`; + state.translationButtonContainer.style.top = `${top}px`; + state.buttonAnchor.x += distanceX; + state.buttonAnchor.y += distanceY; + state.originScrollX = state.scrollingElement[state.scrollPropertyX]; + state.originScrollY = state.scrollingElement[state.scrollPropertyY]; + state.originPositionX = left; + state.originPositionY = top; } export function disappearButton(state) { if (!state.hasButtonShown) return; + cancelAutoAvoidRechecks(state); closeButtonHost(state.translationButtonHost); if (document.documentElement.contains(state.translationButtonContainer)) { document.documentElement.removeChild(state.translationButtonContainer); @@ -72,6 +74,20 @@ export function disappearButton(state) { state.buttonSelection = null; } +function positionButton(state) { + const position = getButtonPosition( + state.buttonPositionSetting, + state.translationButtonContainer, + state.buttonAnchor + ); + state.translationButtonContainer.style.top = `${position.top}px`; + state.translationButtonContainer.style.left = `${position.left}px`; + state.originScrollX = state.scrollingElement[state.scrollPropertyX]; + state.originScrollY = state.scrollingElement[state.scrollPropertyY]; + state.originPositionX = position.left; + state.originPositionY = position.top; +} + function renderButton(state, onMouseDown) { const buttonImage = document.createElement("img"); const translationButton = document.createElement("div"); diff --git a/packages/EdgeTranslate/src/content/select/select_button_auto_avoid.js b/packages/EdgeTranslate/src/content/select/select_button_auto_avoid.js new file mode 100644 index 00000000..5f9e0a61 --- /dev/null +++ b/packages/EdgeTranslate/src/content/select/select_button_auto_avoid.js @@ -0,0 +1,63 @@ +const AUTO_AVOID_POSITION = "AutoAvoid"; +const AUTO_RECHECK_DELAYS = [0, 120, 300, 700, 1400]; +const AUTO_OBSERVER_DURATION = 1600; +const EXTENSION_ELEMENT_SELECTOR = + "#edge-translate-button, #edge-translate-button-host, #edge-translate-root, " + + "#edge-translate-screenshot-overlay"; + +export function scheduleAutoAvoidRechecks(state, reposition) { + cancelAutoAvoidRechecks(state); + if (state.buttonPositionSetting !== AUTO_AVOID_POSITION) return; + + state.autoAvoidRecheckTimers = AUTO_RECHECK_DELAYS.map((delay) => + window.setTimeout(() => recheckPosition(state, reposition), delay) + ); + observeAutoAvoidChanges(state, reposition); +} + +export function cancelAutoAvoidRechecks(state) { + for (const timer of state.autoAvoidRecheckTimers || []) window.clearTimeout(timer); + state.autoAvoidRecheckTimers = []; + state.autoAvoidObserver?.disconnect(); + state.autoAvoidObserver = null; + window.clearTimeout(state.autoAvoidObserverStopTimer); + state.autoAvoidObserverStopTimer = null; + window.clearTimeout(state.autoAvoidMutationTimer); + state.autoAvoidMutationTimer = null; +} + +function observeAutoAvoidChanges(state, reposition) { + if (typeof MutationObserver !== "function") return; + + state.autoAvoidObserver = new MutationObserver((mutations) => { + if (mutations.every((mutation) => isExtensionElement(mutation.target))) return; + scheduleMutationRecheck(state, reposition); + }); + state.autoAvoidObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style", "open", "popover"], + childList: true, + subtree: true, + }); + state.autoAvoidObserverStopTimer = window.setTimeout(() => { + state.autoAvoidObserver?.disconnect(); + state.autoAvoidObserver = null; + }, AUTO_OBSERVER_DURATION); +} + +function scheduleMutationRecheck(state, reposition) { + if (state.autoAvoidMutationTimer !== null) return; + + state.autoAvoidMutationTimer = window.setTimeout(() => { + state.autoAvoidMutationTimer = null; + recheckPosition(state, reposition); + }, 0); +} + +function recheckPosition(state, reposition) { + if (state.hasButtonShown && state.buttonPositionSetting === AUTO_AVOID_POSITION) reposition(); +} + +function isExtensionElement(node) { + return node instanceof Element && Boolean(node.closest(EXTENSION_ELEMENT_SELECTOR)); +} diff --git a/packages/EdgeTranslate/src/content/select/select_helpers.js b/packages/EdgeTranslate/src/content/select/select_helpers.js index 11953913..31d92740 100644 --- a/packages/EdgeTranslate/src/content/select/select_helpers.js +++ b/packages/EdgeTranslate/src/content/select/select_helpers.js @@ -2,6 +2,14 @@ import { getDomain } from "common/scripts/common.js"; import { isPDFjsPDFViewer } from "../common.js"; import { DEFAULT_SETTINGS, getOrSetDefaultSettings } from "common/scripts/settings.js"; +const AUTO_AVOID_POSITION = "AutoAvoid"; +const AUTO_POSITION_ORDER = ["TopRight", "TopLeft", "BottomRight", "BottomLeft"]; +const BUTTON_CLEARANCE = 4; +const COLLISION_SAMPLE_STEP = 8; +const EXTENSION_ELEMENT_SELECTOR = + "#edge-translate-button, #edge-translate-button-host, #edge-translate-root, " + + "#edge-translate-screenshot-overlay"; + export function getSelection() { const selection = window.getSelection(); let text = selection.toString().trim(); @@ -60,6 +68,10 @@ export function getInnerParent(container) { } export function getButtonPosition(positionSetting, container, event) { + if (positionSetting === AUTO_AVOID_POSITION) { + return getAutoButtonPosition(container, event); + } + const offset = resolveButtonOffset(positionSetting, container); let left = event.x + offset.x; let top = event.y + offset.y; @@ -72,6 +84,187 @@ export function getButtonPosition(positionSetting, container, event) { return { left, top }; } +function getAutoButtonPosition(container, event) { + const candidates = AUTO_POSITION_ORDER.map((position) => + resolveButtonPosition(position, container, event) + ).filter((position) => isPositionInViewport(position, container)); + const availableCandidates = candidates.length + ? candidates + : AUTO_POSITION_ORDER.map((position) => + clampButtonPosition(resolveButtonPosition(position, container, event), container) + ); + const floatingElementCache = new Map(); + + return measurePageWithoutButton(container, () => + availableCandidates.reduce((best, position) => { + const score = getButtonCollisionScore(position, container, floatingElementCache); + if (!best || score < best.score) return { position, score }; + return best; + }, null) + ).position; +} + +function measurePageWithoutButton(container, measure) { + const previousVisibility = container.style.getPropertyValue("visibility"); + const previousPriority = container.style.getPropertyPriority("visibility"); + container.style.setProperty("visibility", "hidden", "important"); + try { + return measure(); + } finally { + if (previousVisibility) { + container.style.setProperty("visibility", previousVisibility, previousPriority); + } else { + container.style.removeProperty("visibility"); + } + } +} + +function resolveButtonPosition(position, container, event) { + const offset = resolveButtonOffset(position, container); + return { left: event.x + offset.x, top: event.y + offset.y }; +} + +function isPositionInViewport(position, container) { + return ( + position.left >= 0 && + position.top >= 0 && + position.left + container.clientWidth <= window.innerWidth && + position.top + container.clientHeight <= window.innerHeight + ); +} + +function clampButtonPosition(position, container) { + return { + left: Math.min( + Math.max(position.left, 0), + Math.max(window.innerWidth - container.clientWidth, 0) + ), + top: Math.min( + Math.max(position.top, 0), + Math.max(window.innerHeight - container.clientHeight, 0) + ), + }; +} + +function getButtonCollisionScore(position, container, floatingElementCache) { + const candidateRect = { + left: Math.max(position.left - BUTTON_CLEARANCE, 0), + top: Math.max(position.top - BUTTON_CLEARANCE, 0), + right: Math.min( + position.left + container.clientWidth + BUTTON_CLEARANCE, + window.innerWidth + ), + bottom: Math.min( + position.top + container.clientHeight + BUTTON_CLEARANCE, + window.innerHeight + ), + }; + const floatingElements = new Set(); + let sampledCollisionCount = 0; + + for (const x of getSamplePoints(candidateRect.left, candidateRect.right)) { + for (const y of getSamplePoints(candidateRect.top, candidateRect.bottom)) { + const elements = getFloatingPageElementsAtPoint(x, y, container, floatingElementCache); + if (elements.length > 0) sampledCollisionCount += 1; + for (const element of elements) floatingElements.add(element); + } + } + + const intersectionArea = Array.from(floatingElements).reduce( + (area, element) => + area + getIntersectionArea(candidateRect, element.getBoundingClientRect()), + 0 + ); + return intersectionArea + sampledCollisionCount / 1000; +} + +function getSamplePoints(start, end) { + if (end <= start) return []; + + const points = []; + for (let point = start + 0.5; point < end; point += COLLISION_SAMPLE_STEP) { + points.push(point); + } + const finalPoint = end - 0.5; + if (points.length === 0 || finalPoint - points[points.length - 1] > 1) points.push(finalPoint); + return points; +} + +function getFloatingPageElementsAtPoint(x, y, container, floatingElementCache) { + const elements = + typeof document.elementsFromPoint === "function" + ? document.elementsFromPoint(x, y) + : [document.elementFromPoint?.(x, y)].filter(Boolean); + + return elements + .map((element) => getFloatingPageElement(element, container, floatingElementCache)) + .filter(Boolean); +} + +function getFloatingPageElement(element, container, floatingElementCache) { + if (!(element instanceof Element) || isExtensionElement(element, container)) return null; + + for (let current = element; current; current = current.parentElement) { + if (isExtensionElement(current, container)) return null; + if (!floatingElementCache.has(current)) { + floatingElementCache.set(current, isFloatingElement(current)); + } + if (floatingElementCache.get(current)) return current; + } + return null; +} + +function getIntersectionArea(firstRect, secondRect) { + const width = Math.max( + 0, + Math.min(firstRect.right, secondRect.right) - Math.max(firstRect.left, secondRect.left) + ); + const height = Math.max( + 0, + Math.min(firstRect.bottom, secondRect.bottom) - Math.max(firstRect.top, secondRect.top) + ); + return width * height; +} + +function isExtensionElement(element, container) { + if (element === container || container.contains?.(element)) return true; + return Boolean(element.closest?.(EXTENSION_ELEMENT_SELECTOR)); +} + +function isFloatingElement(element) { + if (matchesTopLayer(element)) return true; + + const style = window.getComputedStyle(element); + const position = style.position || "static"; + if (isInvisibleElement(style)) return false; + return hasFloatingPosition(position, style.zIndex); +} + +function isInvisibleElement(style) { + return ( + style.display === "none" || + style.visibility === "hidden" || + (style.opacity !== "" && Number(style.opacity) === 0) + ); +} + +function hasFloatingPosition(position, zIndex) { + return ( + position === "fixed" || + position === "sticky" || + position === "absolute" || + (position !== "static" && zIndex !== "" && zIndex !== "auto") + ); +} + +function matchesTopLayer(element) { + try { + return element.matches(":modal, :popover-open"); + } catch { + return false; + } +} + export function syncChangedSettings(state, changes, area, cancelLongPressSession) { if (area !== "sync") return; if (changes.LayoutSettings) { diff --git a/packages/EdgeTranslate/src/options/options.html b/packages/EdgeTranslate/src/options/options.html index 510042df..135fb4c8 100644 --- a/packages/EdgeTranslate/src/options/options.html +++ b/packages/EdgeTranslate/src/options/options.html @@ -333,6 +333,11 @@ value="SelectTranslatePosition" setting-path="LayoutSettings SelectTranslatePosition" > +