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..38810704 100644
--- a/packages/EdgeTranslate/src/content/select/select.js
+++ b/packages/EdgeTranslate/src/content/select/select.js
@@ -46,8 +46,11 @@ function initSelectTranslate() {
function createSelectState() {
return {
- buttonPositionSetting: "TopRight",
+ buttonPositionSetting: "AutoAvoid",
+ buttonAnchor: null,
+ buttonPlacementDirection: null,
buttonSelection: null,
+ buttonShownAt: null,
channel: new Channel(),
hasButtonShown: false,
longPressEnabled: false,
@@ -59,6 +62,7 @@ function createSelectState() {
originScrollX: 0,
originScrollY: 0,
screenshotSelector: createScreenshotSelector(),
+ selectionActionGeneration: 0,
scrollPropertyX: "pageXOffset",
scrollPropertyY: "pageYOffset",
scrollingElement: window,
@@ -102,6 +106,7 @@ function initializeDomListeners(state) {
}
state.scrollingElement.addEventListener("scroll", () => scrollHandler(state));
document.addEventListener("mousedown", () => {
+ state.selectionActionGeneration += 1;
disappearButton(state);
detectSelect(document, (event) => selectTranslate(state, event));
});
@@ -119,9 +124,11 @@ function initializeDomListeners(state) {
function selectTranslate(state, event, isDoubleClick = false) {
if (!shouldTranslate()) return;
+ const actionGeneration = ++state.selectionActionGeneration;
isInBlacklist().then((inBlacklist) => {
- if (inBlacklist) return;
+ if (inBlacklist || actionGeneration !== state.selectionActionGeneration) return;
getOrSetDefaultSettings("OtherSettings", DEFAULT_SETTINGS).then((result) => {
+ if (actionGeneration !== state.selectionActionGeneration) return;
const otherSettings = result.OtherSettings;
if (!otherSettings) return;
if (
diff --git a/packages/EdgeTranslate/src/content/select/select_button.js b/packages/EdgeTranslate/src/content/select/select_button.js
index 315adb28..2a537fce 100644
--- a/packages/EdgeTranslate/src/content/select/select_button.js
+++ b/packages/EdgeTranslate/src/content/select/select_button.js
@@ -2,22 +2,21 @@ import { IMAGE_DATA } from "./select_constants.js";
import {
applyButtonImageStyle,
applyButtonStyle,
- getButtonPosition,
getInnerParent,
getSelection,
} from "./select_helpers.js";
+import { cancelAutoAvoidRechecks, scheduleAutoAvoidRechecks } from "./select_button_auto_avoid.js";
+import { getButtonAnchor } from "./select_button_anchor.js";
+import { closeButtonHost, createButtonHost, showButtonLayer } from "./select_button_layer.js";
+import { getButtonPlacement } from "./select_button_position.js";
+import {
+ clearPendingDirectionChange,
+ isDuplicateButtonRequest,
+ shiftButtonAnchor,
+ shouldDeferDirectionChange,
+} from "./select_button_session.js";
-const BUTTON_HOST_ID = "edge-translate-button-host";
const BUTTON_ID = "edge-translate-button";
-const PANEL_ROOT_ID = "edge-translate-root";
-const SCREENSHOT_OVERLAY_ID = "edge-translate-screenshot-overlay";
-const BACKDROP_STYLE_ID = "edge-translate-button-host-backdrop-style";
-
-const LayerMode = {
- Modal: "modal",
- Normal: "normal",
- Popover: "popover",
-};
export function initializeButtonContainer(state, onMouseDown) {
const iframeContainer = state.translationButtonContainer;
@@ -31,45 +30,82 @@ export function initializeButtonContainer(state, onMouseDown) {
state.translationButtonContainer.id = BUTTON_ID;
state.translationButtonContainer.style.backgroundColor = "white";
state.translationButtonContainer.style.pointerEvents = "auto";
- state.translationButtonHost = createButtonHost(state);
+ state.translationButtonHost = createButtonHost(state, () => disappearButton(state));
state.translationButtonContainer.addEventListener("load", () =>
renderButton(state, onMouseDown)
);
}
export function showButton(state, event) {
- state.buttonSelection = getSelection();
+ const selection = getSelection();
+ const anchor = getButtonAnchor(event);
+ if (isDuplicateButtonRequest(state, selection, anchor)) return;
+
+ state.buttonSelection = selection;
+ state.buttonAnchor = anchor;
+ state.buttonPlacementDirection = null;
+ state.buttonShownAt = Date.now();
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, () => {
+ showButtonLayer(state);
+ positionButton(state, { isRecheck: true });
+ });
}
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`;
+ shiftButtonAnchor(state.buttonAnchor, distanceX, 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);
}
state.hasButtonShown = false;
+ state.buttonAnchor = null;
+ state.buttonPlacementDirection = null;
state.buttonSelection = null;
+ state.buttonShownAt = null;
+}
+
+function positionButton(state, { isRecheck = false } = {}) {
+ const placement = getButtonPlacement(
+ state.buttonPositionSetting,
+ state.translationButtonContainer,
+ state.buttonAnchor,
+ { currentDirection: state.buttonPlacementDirection }
+ );
+ if (
+ shouldDeferDirectionChange(state, placement, isRecheck, () =>
+ positionButton(state, { isRecheck: true })
+ )
+ ) {
+ return;
+ }
+
+ clearPendingDirectionChange(state);
+ state.buttonPlacementDirection = placement.direction;
+ state.translationButtonContainer.style.top = `${placement.top}px`;
+ state.translationButtonContainer.style.left = `${placement.left}px`;
+ state.originScrollX = state.scrollingElement[state.scrollPropertyX];
+ state.originScrollY = state.scrollingElement[state.scrollPropertyY];
+ state.originPositionX = placement.left;
+ state.originPositionY = placement.top;
}
function renderButton(state, onMouseDown) {
@@ -90,189 +126,3 @@ function renderButton(state, onMouseDown) {
translationButton.addEventListener("mousedown", onMouseDown);
translationButton.addEventListener("contextmenu", (event) => event.preventDefault());
}
-
-function createButtonHost(state) {
- const host = document.createElement("dialog");
- host.id = BUTTON_HOST_ID;
- host.popover = "manual";
- host.dataset.edgeTranslateLayerMode = LayerMode.Normal;
- Object.assign(host.style, {
- position: "fixed",
- display: "block",
- top: 0,
- left: 0,
- zIndex: 2147483647,
- border: "none",
- padding: 0,
- margin: 0,
- background: "transparent",
- width: 0,
- height: 0,
- minWidth: 0,
- minHeight: 0,
- maxWidth: "none",
- maxHeight: "none",
- pointerEvents: "none",
- overflow: "visible",
- });
- host.addEventListener("cancel", (event) => {
- event.preventDefault();
- disappearButton(state);
- });
- host.addEventListener("mousedown", (event) => dismissFromHost(event, state), true);
- ensureTransparentBackdropStyle();
- return host;
-}
-
-function showButtonLayer(state) {
- const host = state.translationButtonHost;
- if (hasModalTopLayerBlocker(host) || hasOpenPopover(host)) {
- showButtonHost(state);
- return;
- }
-
- showNormalButton(state);
-}
-
-function showNormalButton(state) {
- closeButtonHost(state.translationButtonHost);
- if (!document.documentElement.contains(state.translationButtonContainer)) {
- document.documentElement.appendChild(state.translationButtonContainer);
- }
-}
-
-function showButtonHost(state) {
- const host = state.translationButtonHost;
- if (!host.contains(state.translationButtonContainer)) {
- host.appendChild(state.translationButtonContainer);
- }
- if (!document.documentElement.contains(host)) {
- document.documentElement.appendChild(host);
- }
-
- if (hasModalTopLayerBlocker(host)) {
- showPopoverButtonHost(state);
- return;
- }
- if (hasOpenPopover(host)) {
- showPopoverButtonHost(state);
- return;
- }
- showNormalButton(state);
-}
-
-function showPopoverButtonHost(state) {
- const host = state.translationButtonHost;
- if (typeof host.showPopover !== "function") {
- showNormalButton(state);
- return;
- }
-
- if (getLayerMode(host) === LayerMode.Modal) closeDialog(host);
- try {
- host.showPopover();
- setLayerMode(host, LayerMode.Popover);
- } catch {
- showNormalButton(state);
- }
-}
-
-function closeButtonHost(host) {
- if (!host) return;
- if (getLayerMode(host) === LayerMode.Popover) hidePopover(host);
- if (host.open) closeDialog(host);
- setLayerMode(host, LayerMode.Normal);
- if (document.documentElement.contains(host)) {
- document.documentElement.removeChild(host);
- }
-}
-
-function hasModalTopLayerBlocker(host) {
- if (document.fullscreenElement) return true;
-
- return Array.from(document.querySelectorAll("dialog")).some(
- (dialog) => !isExtensionLayer(dialog, host) && isModalDialog(dialog)
- );
-}
-
-function hasOpenPopover(host) {
- return Array.from(document.querySelectorAll("[popover]")).some(
- (element) => !isExtensionLayer(element, host) && isPopoverOpen(element)
- );
-}
-
-function isModalDialog(dialog) {
- try {
- return dialog.matches(":modal");
- } catch {
- return dialog.open;
- }
-}
-
-function isPopoverOpen(element) {
- try {
- return element.matches(":popover-open");
- } catch {
- return false;
- }
-}
-
-function isExtensionLayer(element, host) {
- return (
- element === host ||
- element.id === BUTTON_HOST_ID ||
- element.id === BUTTON_ID ||
- element.id === PANEL_ROOT_ID ||
- element.id === SCREENSHOT_OVERLAY_ID
- );
-}
-
-function closeDialog(host) {
- if (typeof host.close === "function") {
- try {
- host.close();
- return;
- } catch {
- // Fall back to removing the open attribute below.
- }
- }
-
- host.removeAttribute("open");
-}
-
-function hidePopover(host) {
- if (typeof host.hidePopover !== "function") return;
-
- try {
- host.hidePopover();
- } catch {
- // The host may already be hidden or the browser may not support popovers.
- }
-}
-
-function getLayerMode(host) {
- return host.dataset.edgeTranslateLayerMode || LayerMode.Normal;
-}
-
-function setLayerMode(host, mode) {
- host.dataset.edgeTranslateLayerMode = mode;
- host.style.pointerEvents = "none";
-}
-
-function dismissFromHost(event, state) {
- if (event.target !== state.translationButtonHost) return;
-
- event.preventDefault();
- event.stopPropagation();
- event.stopImmediatePropagation?.();
- disappearButton(state);
-}
-
-function ensureTransparentBackdropStyle() {
- if (document.getElementById(BACKDROP_STYLE_ID)) return;
-
- const style = document.createElement("style");
- style.id = BACKDROP_STYLE_ID;
- style.textContent = `#${BUTTON_HOST_ID}::backdrop { background: transparent; }`;
- (document.head || document.documentElement).appendChild(style);
-}
diff --git a/packages/EdgeTranslate/src/content/select/select_button_anchor.js b/packages/EdgeTranslate/src/content/select/select_button_anchor.js
new file mode 100644
index 00000000..b40cd017
--- /dev/null
+++ b/packages/EdgeTranslate/src/content/select/select_button_anchor.js
@@ -0,0 +1,62 @@
+export function getButtonAnchor(event) {
+ const point = getEventPoint(event);
+ const selectionRect = getSelectionRectNearestPoint(point);
+ return { ...point, selectionRect };
+}
+
+function getEventPoint(event) {
+ const x = Number(event?.clientX ?? event?.x);
+ const y = Number(event?.clientY ?? event?.y);
+ return {
+ x: Number.isFinite(x) ? x : 0,
+ y: Number.isFinite(y) ? y : 0,
+ };
+}
+
+function getSelectionRectNearestPoint(point) {
+ const selection = window.getSelection?.();
+ if (!selection?.rangeCount) return null;
+
+ const range = selection.getRangeAt(selection.rangeCount - 1);
+ const clientRects = getValidClientRects(range);
+ if (clientRects.length === 0) return null;
+
+ return clientRects.reduce((nearest, rect) => {
+ const distance = getPointDistanceToRect(point, rect);
+ if (!nearest || distance < nearest.distance) return { distance, rect };
+ return nearest;
+ }, null).rect;
+}
+
+function getValidClientRects(range) {
+ let rects = [];
+ try {
+ rects = Array.from(range.getClientRects?.() || []);
+ if (rects.length === 0) rects = [range.getBoundingClientRect?.()].filter(Boolean);
+ } catch {
+ return [];
+ }
+
+ return rects
+ .map((rect) => ({
+ bottom: Number(rect.bottom),
+ left: Number(rect.left),
+ right: Number(rect.right),
+ top: Number(rect.top),
+ }))
+ .filter(isValidRect);
+}
+
+function isValidRect(rect) {
+ return (
+ Object.values(rect).every(Number.isFinite) &&
+ rect.right > rect.left &&
+ rect.bottom > rect.top
+ );
+}
+
+function getPointDistanceToRect(point, rect) {
+ const distanceX = Math.max(rect.left - point.x, 0, point.x - rect.right);
+ const distanceY = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
+ return distanceX * distanceX + distanceY * distanceY;
+}
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..dce307c3
--- /dev/null
+++ b/packages/EdgeTranslate/src/content/select/select_button_auto_avoid.js
@@ -0,0 +1,67 @@
+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;
+ window.clearTimeout(state.autoAvoidDirectionConfirmTimer);
+ state.autoAvoidDirectionConfirmTimer = null;
+ state.autoAvoidPendingDirection = null;
+ state.autoAvoidPendingSince = 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_button_collision.js b/packages/EdgeTranslate/src/content/select/select_button_collision.js
new file mode 100644
index 00000000..f671594f
--- /dev/null
+++ b/packages/EdgeTranslate/src/content/select/select_button_collision.js
@@ -0,0 +1,206 @@
+const BLOCKED_OVERLAP_RATIO = 0.25;
+const BLOCKED_SAMPLE_COUNT = 3;
+const COLLISION_SAMPLE_RATIOS = [0.2, 0.5, 0.8];
+const MAX_GEOMETRIC_OVERLAY_VIEWPORT_RATIO = 0.25;
+const EXTENSION_ELEMENT_SELECTOR =
+ "#edge-translate-button, #edge-translate-button-host, #edge-translate-root, " +
+ "#edge-translate-screenshot-overlay";
+
+export function getButtonCollision(position, container, floatingElementCache) {
+ const candidateRect = getButtonRect(position, container);
+ const stats = createCollisionStats();
+
+ for (const xRatio of COLLISION_SAMPLE_RATIOS) {
+ for (const yRatio of COLLISION_SAMPLE_RATIOS) {
+ collectCollisionSample(stats, {
+ candidateRect,
+ container,
+ floatingElementCache,
+ xRatio,
+ yRatio,
+ });
+ }
+ }
+
+ const overlapArea = getUnionIntersectionArea(
+ candidateRect,
+ Array.from(stats.floatingElements, (element) => element.getBoundingClientRect())
+ );
+ const buttonArea = Math.max(container.clientWidth * container.clientHeight, 1);
+ const overlapRatio = overlapArea / buttonArea;
+ const hardBlocked = stats.centerOccluded || stats.occludedSampleCount >= BLOCKED_SAMPLE_COUNT;
+ const softBlocked =
+ stats.centerOverlapped ||
+ stats.overlappingSampleCount >= BLOCKED_SAMPLE_COUNT ||
+ overlapRatio >= BLOCKED_OVERLAP_RATIO;
+ return {
+ blocked: hardBlocked || softBlocked,
+ hardBlocked,
+ overlapRatio,
+ softBlocked,
+ ...stats,
+ };
+}
+
+function createCollisionStats() {
+ return {
+ centerOccluded: false,
+ centerOverlapped: false,
+ floatingElements: new Set(),
+ occludedSampleCount: 0,
+ overlappingSampleCount: 0,
+ };
+}
+
+function collectCollisionSample(
+ stats,
+ { candidateRect, container, floatingElementCache, xRatio, yRatio }
+) {
+ const x = candidateRect.left + container.clientWidth * xRatio;
+ const y = candidateRect.top + container.clientHeight * yRatio;
+ const collision = getCollisionAtPoint(x, y, container, floatingElementCache);
+ const isCenter = xRatio === 0.5 && yRatio === 0.5;
+
+ if (collision.floatingElements.length > 0) stats.overlappingSampleCount += 1;
+ if (collision.occludingElements.length > 0) stats.occludedSampleCount += 1;
+ if (isCenter) {
+ stats.centerOverlapped = collision.floatingElements.length > 0;
+ stats.centerOccluded = collision.occludingElements.length > 0;
+ }
+ for (const element of collision.floatingElements) stats.floatingElements.add(element);
+}
+
+function getButtonRect(position, container) {
+ return {
+ bottom: position.top + container.clientHeight,
+ left: position.left,
+ right: position.left + container.clientWidth,
+ top: position.top,
+ };
+}
+
+function getCollisionAtPoint(x, y, container, floatingElementCache) {
+ const elements =
+ typeof document.elementsFromPoint === "function"
+ ? document.elementsFromPoint(x, y)
+ : [document.elementFromPoint?.(x, y)].filter(Boolean);
+ const extensionIndex = elements.findIndex((element) => isExtensionElement(element, container));
+ const floatingElements = [];
+ const occludingElements = [];
+
+ for (const [index, element] of elements.entries()) {
+ if (!(element instanceof Element) || isExtensionElement(element, container)) continue;
+ const floatingElement = getFloatingPageElement(element, container, floatingElementCache);
+ if (floatingElement) floatingElements.push(floatingElement);
+ if (extensionIndex >= 0 && index < extensionIndex) {
+ occludingElements.push(floatingElement || element);
+ }
+ }
+ return { floatingElements, occludingElements };
+}
+
+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 getUnionIntersectionArea(candidateRect, rectangles) {
+ const intersections = getIntersectionRects(candidateRect, rectangles);
+ const xCoordinates = Array.from(
+ new Set(intersections.flatMap((rect) => [rect.left, rect.right]))
+ ).sort((first, second) => first - second);
+ let area = 0;
+
+ for (let index = 0; index < xCoordinates.length - 1; index += 1) {
+ const left = xCoordinates[index];
+ const right = xCoordinates[index + 1];
+ area += (right - left) * getCoveredHeight(intersections, left, right);
+ }
+ return area;
+}
+
+function getIntersectionRects(candidateRect, rectangles) {
+ return rectangles
+ .map((rect) => ({
+ bottom: Math.min(candidateRect.bottom, rect.bottom),
+ left: Math.max(candidateRect.left, rect.left),
+ right: Math.min(candidateRect.right, rect.right),
+ top: Math.max(candidateRect.top, rect.top),
+ }))
+ .filter((rect) => rect.right > rect.left && rect.bottom > rect.top);
+}
+
+function getCoveredHeight(rectangles, left, right) {
+ const intervals = rectangles
+ .filter((rect) => rect.left < right && rect.right > left)
+ .map((rect) => [rect.top, rect.bottom])
+ .sort((first, second) => first[0] - second[0]);
+ let coveredHeight = 0;
+ let currentInterval = null;
+
+ for (const interval of intervals) {
+ if (!currentInterval || interval[0] > currentInterval[1]) {
+ if (currentInterval) coveredHeight += currentInterval[1] - currentInterval[0];
+ currentInterval = [...interval];
+ } else {
+ currentInterval[1] = Math.max(currentInterval[1], interval[1]);
+ }
+ }
+ if (currentInterval) coveredHeight += currentInterval[1] - currentInterval[0];
+ return coveredHeight;
+}
+
+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;
+ if (!hasFloatingPosition(position, style.zIndex)) return false;
+ return !isLargeLayoutElement(element);
+}
+
+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" && zIndex !== "" && zIndex !== "auto")
+ );
+}
+
+function isLargeLayoutElement(element) {
+ const rect = element.getBoundingClientRect();
+ const width = Math.max(Number(rect.width) || rect.right - rect.left, 0);
+ const height = Math.max(Number(rect.height) || rect.bottom - rect.top, 0);
+ const viewportArea = Math.max(window.innerWidth * window.innerHeight, 1);
+ return width * height >= viewportArea * MAX_GEOMETRIC_OVERLAY_VIEWPORT_RATIO;
+}
+
+function matchesTopLayer(element) {
+ try {
+ return element.matches(":modal, :popover-open");
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/EdgeTranslate/src/content/select/select_button_layer.js b/packages/EdgeTranslate/src/content/select/select_button_layer.js
new file mode 100644
index 00000000..f865a661
--- /dev/null
+++ b/packages/EdgeTranslate/src/content/select/select_button_layer.js
@@ -0,0 +1,183 @@
+const BUTTON_HOST_ID = "edge-translate-button-host";
+const BUTTON_ID = "edge-translate-button";
+const PANEL_ROOT_ID = "edge-translate-root";
+const SCREENSHOT_OVERLAY_ID = "edge-translate-screenshot-overlay";
+const BACKDROP_STYLE_ID = "edge-translate-button-host-backdrop-style";
+
+const LayerMode = {
+ Modal: "modal",
+ Normal: "normal",
+ Popover: "popover",
+};
+
+export function createButtonHost(state, onDismiss) {
+ const host = document.createElement("dialog");
+ host.id = BUTTON_HOST_ID;
+ host.popover = "manual";
+ host.dataset.edgeTranslateLayerMode = LayerMode.Normal;
+ Object.assign(host.style, {
+ position: "fixed",
+ display: "block",
+ top: 0,
+ left: 0,
+ zIndex: 2147483647,
+ border: "none",
+ padding: 0,
+ margin: 0,
+ background: "transparent",
+ width: 0,
+ height: 0,
+ minWidth: 0,
+ minHeight: 0,
+ maxWidth: "none",
+ maxHeight: "none",
+ pointerEvents: "none",
+ overflow: "visible",
+ });
+ host.addEventListener("cancel", (event) => {
+ event.preventDefault();
+ onDismiss();
+ });
+ host.addEventListener("mousedown", (event) => dismissFromHost(event, state, onDismiss), true);
+ ensureTransparentBackdropStyle();
+ return host;
+}
+
+export function showButtonLayer(state) {
+ const host = state.translationButtonHost;
+ if (hasModalTopLayerBlocker(host) || hasOpenPopover(host)) {
+ showButtonHost(state);
+ return;
+ }
+ showNormalButton(state);
+}
+
+export function closeButtonHost(host) {
+ if (!host) return;
+ if (getLayerMode(host) === LayerMode.Popover) hidePopover(host);
+ if (host.open) closeDialog(host);
+ setLayerMode(host, LayerMode.Normal);
+ if (document.documentElement.contains(host)) document.documentElement.removeChild(host);
+}
+
+function showNormalButton(state) {
+ closeButtonHost(state.translationButtonHost);
+ if (!document.documentElement.contains(state.translationButtonContainer)) {
+ document.documentElement.appendChild(state.translationButtonContainer);
+ }
+}
+
+function showButtonHost(state) {
+ const host = state.translationButtonHost;
+ if (!host.contains(state.translationButtonContainer)) {
+ host.appendChild(state.translationButtonContainer);
+ }
+ if (!document.documentElement.contains(host)) document.documentElement.appendChild(host);
+
+ if (hasModalTopLayerBlocker(host) || hasOpenPopover(host)) {
+ showPopoverButtonHost(state);
+ return;
+ }
+ showNormalButton(state);
+}
+
+function showPopoverButtonHost(state) {
+ const host = state.translationButtonHost;
+ if (typeof host.showPopover !== "function") {
+ showNormalButton(state);
+ return;
+ }
+
+ if (getLayerMode(host) === LayerMode.Modal) closeDialog(host);
+ try {
+ host.showPopover();
+ setLayerMode(host, LayerMode.Popover);
+ } catch {
+ showNormalButton(state);
+ }
+}
+
+function hasModalTopLayerBlocker(host) {
+ if (document.fullscreenElement) return true;
+ return Array.from(document.querySelectorAll("dialog")).some(
+ (dialog) => !isExtensionLayer(dialog, host) && isModalDialog(dialog)
+ );
+}
+
+function hasOpenPopover(host) {
+ return Array.from(document.querySelectorAll("[popover]")).some(
+ (element) => !isExtensionLayer(element, host) && isPopoverOpen(element)
+ );
+}
+
+function isModalDialog(dialog) {
+ try {
+ return dialog.matches(":modal");
+ } catch {
+ return dialog.open;
+ }
+}
+
+function isPopoverOpen(element) {
+ try {
+ return element.matches(":popover-open");
+ } catch {
+ return false;
+ }
+}
+
+function isExtensionLayer(element, host) {
+ return (
+ element === host ||
+ element.id === BUTTON_HOST_ID ||
+ element.id === BUTTON_ID ||
+ element.id === PANEL_ROOT_ID ||
+ element.id === SCREENSHOT_OVERLAY_ID
+ );
+}
+
+function closeDialog(host) {
+ if (typeof host.close === "function") {
+ try {
+ host.close();
+ return;
+ } catch {
+ // Fall back to removing the open attribute below.
+ }
+ }
+ host.removeAttribute("open");
+}
+
+function hidePopover(host) {
+ if (typeof host.hidePopover !== "function") return;
+ try {
+ host.hidePopover();
+ } catch {
+ // The host may already be hidden or the browser may not support popovers.
+ }
+}
+
+function getLayerMode(host) {
+ return host.dataset.edgeTranslateLayerMode || LayerMode.Normal;
+}
+
+function setLayerMode(host, mode) {
+ host.dataset.edgeTranslateLayerMode = mode;
+ host.style.pointerEvents = "none";
+}
+
+function dismissFromHost(event, state, onDismiss) {
+ if (event.target !== state.translationButtonHost) return;
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation?.();
+ onDismiss();
+}
+
+function ensureTransparentBackdropStyle() {
+ if (document.getElementById(BACKDROP_STYLE_ID)) return;
+ const style = document.createElement("style");
+ style.id = BACKDROP_STYLE_ID;
+ style.textContent = `#${BUTTON_HOST_ID}::backdrop { background: transparent; }`;
+ (document.head || document.documentElement).appendChild(style);
+}
diff --git a/packages/EdgeTranslate/src/content/select/select_button_position.js b/packages/EdgeTranslate/src/content/select/select_button_position.js
new file mode 100644
index 00000000..fc975e63
--- /dev/null
+++ b/packages/EdgeTranslate/src/content/select/select_button_position.js
@@ -0,0 +1,263 @@
+import { getButtonCollision } from "./select_button_collision.js";
+
+const AUTO_AVOID_POSITION = "AutoAvoid";
+const AUTO_POSITION_ORDER = ["TopRight", "BottomRight", "TopLeft", "BottomLeft"];
+const BUTTON_GAP = 10;
+const BUTTON_VIEWPORT_PADDING = 4;
+const MAX_VIEWPORT_NUDGE = 12;
+const BLOCKED_OVERLAP_RATIO = 0.25;
+
+export function getButtonPosition(positionSetting, container, anchor) {
+ const { left, top } = getButtonPlacement(positionSetting, container, anchor);
+ return { left, top };
+}
+
+export function getButtonPlacement(positionSetting, container, anchor, options = {}) {
+ if (positionSetting === AUTO_AVOID_POSITION) {
+ return getAutoButtonPlacement(container, anchor, options);
+ }
+
+ const offset = resolveButtonOffset(positionSetting, container);
+ let left = anchor.x + offset.x;
+ let top = anchor.y + offset.y;
+ if (left <= 0 || left + container.clientWidth > window.innerWidth) {
+ left = anchor.x - offset.x - container.clientWidth;
+ }
+ if (top <= 0 || top + container.clientHeight > window.innerHeight) {
+ top = anchor.y - offset.y - container.clientHeight;
+ }
+ return { direction: positionSetting, left, top };
+}
+
+function getAutoButtonPlacement(container, anchor, options) {
+ const candidates = AUTO_POSITION_ORDER.map((direction) =>
+ createButtonCandidate(direction, container, anchor)
+ );
+ const floatingElementCache = new Map();
+
+ return measureCandidatesWithButton(container, () => {
+ const evaluatedCandidates = candidates.map((candidate) =>
+ evaluateCandidate(candidate, container, floatingElementCache)
+ );
+ const currentCandidate = evaluatedCandidates.find(
+ (candidate) => candidate.direction === options.currentDirection
+ );
+ if (currentCandidate?.usable) return toButtonPlacement(currentCandidate);
+
+ const orderedCandidates = orderCandidatesAfterCurrent(
+ evaluatedCandidates,
+ options.currentDirection
+ );
+ const usableCandidate = orderedCandidates.find((candidate) => candidate.usable);
+ if (usableCandidate) return toButtonPlacement(usableCandidate);
+
+ const leastObstructedCandidate = getLeastObstructedCandidate(evaluatedCandidates);
+ if (
+ currentCandidate &&
+ !isMeaningfullyBetterCandidate(leastObstructedCandidate, currentCandidate)
+ ) {
+ return toButtonPlacement(currentCandidate);
+ }
+ return toButtonPlacement(leastObstructedCandidate);
+ });
+}
+
+function evaluateCandidate(candidate, container, floatingElementCache) {
+ setButtonPositionForMeasurement(container, candidate);
+ const collision = getButtonCollision(candidate, container, floatingElementCache);
+ return {
+ ...candidate,
+ collision,
+ usable: candidate.viewportUsable && !candidate.overlapsSelection && !collision.blocked,
+ };
+}
+
+function createButtonCandidate(direction, container, anchor) {
+ const rawPosition = resolveButtonPosition(direction, container, anchor);
+ const position = nudgeButtonIntoViewport(rawPosition, container);
+ const buttonRect = getButtonRect(position, container);
+ const buttonArea = Math.max(container.clientWidth * container.clientHeight, 1);
+ const selectionOverlapArea = anchor.selectionRect
+ ? getIntersectionArea(buttonRect, anchor.selectionRect)
+ : 0;
+ const nudgeDistance = Math.max(
+ Math.abs(position.left - rawPosition.left),
+ Math.abs(position.top - rawPosition.top)
+ );
+
+ return {
+ direction,
+ left: position.left,
+ nudgeDistance,
+ overlapsSelection: selectionOverlapArea / buttonArea >= BLOCKED_OVERLAP_RATIO,
+ top: position.top,
+ viewportUsable:
+ nudgeDistance <= MAX_VIEWPORT_NUDGE && isPositionInViewport(position, container),
+ };
+}
+
+function measureCandidatesWithButton(container, measure) {
+ const properties = ["left", "opacity", "top"];
+ const previousStyles = properties.map((property) => ({
+ property,
+ priority: container.style.getPropertyPriority(property),
+ value: container.style.getPropertyValue(property),
+ }));
+ container.style.setProperty("opacity", "0", "important");
+ try {
+ return measure();
+ } finally {
+ restoreStyles(container, previousStyles);
+ }
+}
+
+function restoreStyles(container, styles) {
+ for (const { priority, property, value } of styles) {
+ if (value) container.style.setProperty(property, value, priority);
+ else container.style.removeProperty(property);
+ }
+}
+
+function setButtonPositionForMeasurement(container, position) {
+ container.style.left = `${position.left}px`;
+ container.style.top = `${position.top}px`;
+}
+
+function resolveButtonPosition(direction, container, anchor) {
+ if (!anchor.selectionRect) {
+ const offset = resolveButtonOffset(direction, container);
+ return { left: anchor.x + offset.x, top: anchor.y + offset.y };
+ }
+
+ return resolveSelectionButtonPosition(direction, container, anchor.selectionRect);
+}
+
+function resolveSelectionButtonPosition(direction, container, rect) {
+ switch (direction) {
+ case "TopLeft":
+ return {
+ left: rect.left - BUTTON_GAP - container.clientWidth,
+ top: rect.top - BUTTON_GAP - container.clientHeight,
+ };
+ case "BottomRight":
+ return { left: rect.right + BUTTON_GAP, top: rect.bottom + BUTTON_GAP };
+ case "BottomLeft":
+ return {
+ left: rect.left - BUTTON_GAP - container.clientWidth,
+ top: rect.bottom + BUTTON_GAP,
+ };
+ case "TopRight":
+ default:
+ return {
+ left: rect.right + BUTTON_GAP,
+ top: rect.top - BUTTON_GAP - container.clientHeight,
+ };
+ }
+}
+
+function isPositionInViewport(position, container) {
+ return (
+ position.left >= 0 &&
+ position.top >= 0 &&
+ position.left + container.clientWidth <= window.innerWidth &&
+ position.top + container.clientHeight <= window.innerHeight
+ );
+}
+
+function nudgeButtonIntoViewport(position, container) {
+ const maximumLeft = Math.max(
+ window.innerWidth - container.clientWidth - BUTTON_VIEWPORT_PADDING,
+ BUTTON_VIEWPORT_PADDING
+ );
+ const maximumTop = Math.max(
+ window.innerHeight - container.clientHeight - BUTTON_VIEWPORT_PADDING,
+ BUTTON_VIEWPORT_PADDING
+ );
+ return {
+ left: Math.min(Math.max(position.left, BUTTON_VIEWPORT_PADDING), maximumLeft),
+ top: Math.min(Math.max(position.top, BUTTON_VIEWPORT_PADDING), maximumTop),
+ };
+}
+
+function getButtonRect(position, container) {
+ return {
+ bottom: position.top + container.clientHeight,
+ left: position.left,
+ right: position.left + container.clientWidth,
+ top: position.top,
+ };
+}
+
+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 orderCandidatesAfterCurrent(candidates, currentDirection) {
+ const currentIndex = candidates.findIndex(
+ (candidate) => candidate.direction === currentDirection
+ );
+ if (currentIndex < 0) return candidates;
+ return candidates.slice(currentIndex + 1).concat(candidates.slice(0, currentIndex + 1));
+}
+
+function getLeastObstructedCandidate(candidates) {
+ return candidates.reduce((best, candidate) => {
+ if (!best || getCandidatePenalty(candidate) < getCandidatePenalty(best)) return candidate;
+ return best;
+ }, null);
+}
+
+function isMeaningfullyBetterCandidate(candidate, currentCandidate) {
+ return getCandidateSeverity(candidate) < getCandidateSeverity(currentCandidate);
+}
+
+function getCandidateSeverity(candidate) {
+ if (candidate.collision.hardBlocked) return 4;
+ if (!candidate.viewportUsable) return 3;
+ if (candidate.overlapsSelection) return 2;
+ if (candidate.collision.softBlocked) return 1;
+ return 0;
+}
+
+function getCandidatePenalty(candidate) {
+ return (
+ Number(candidate.collision.centerOccluded) * 1000000 +
+ candidate.collision.occludedSampleCount * 100000 +
+ Number(candidate.collision.centerOverlapped) * 50000 +
+ candidate.collision.overlapRatio * 10000 +
+ Number(candidate.overlapsSelection) * 1000 +
+ candidate.nudgeDistance
+ );
+}
+
+function toButtonPlacement(candidate) {
+ return {
+ direction: candidate.direction,
+ left: candidate.left,
+ top: candidate.top,
+ };
+}
+
+function resolveButtonOffset(position, container) {
+ const offsetX = 10;
+ const offsetY = 20;
+ switch (position) {
+ case "TopLeft":
+ return { x: -offsetX - container.clientWidth, y: -offsetY - container.clientHeight };
+ case "BottomRight":
+ return { x: offsetX, y: offsetY };
+ case "BottomLeft":
+ return { x: -offsetX - container.clientWidth, y: offsetY };
+ case "TopRight":
+ default:
+ return { x: offsetX, y: -offsetY - container.clientHeight };
+ }
+}
diff --git a/packages/EdgeTranslate/src/content/select/select_button_session.js b/packages/EdgeTranslate/src/content/select/select_button_session.js
new file mode 100644
index 00000000..14f50cc9
--- /dev/null
+++ b/packages/EdgeTranslate/src/content/select/select_button_session.js
@@ -0,0 +1,72 @@
+const AUTO_AVOID_POSITION = "AutoAvoid";
+const AUTO_AVOID_DIRECTION_CONFIRM_DELAY = 50;
+const DUPLICATE_SELECTION_WINDOW = 250;
+
+export function isDuplicateButtonRequest(state, selection, anchor) {
+ return (
+ state.hasButtonShown &&
+ state.buttonSelection?.text === selection.text &&
+ Date.now() - state.buttonShownAt <= DUPLICATE_SELECTION_WINDOW &&
+ areButtonAnchorsEqual(state.buttonAnchor, anchor)
+ );
+}
+
+export function shiftButtonAnchor(anchor, distanceX, distanceY) {
+ if (!anchor) return;
+ anchor.x += distanceX;
+ anchor.y += distanceY;
+ if (!anchor.selectionRect) return;
+ anchor.selectionRect = {
+ bottom: anchor.selectionRect.bottom + distanceY,
+ left: anchor.selectionRect.left + distanceX,
+ right: anchor.selectionRect.right + distanceX,
+ top: anchor.selectionRect.top + distanceY,
+ };
+}
+
+export function shouldDeferDirectionChange(state, placement, isRecheck, confirmDirectionChange) {
+ if (!isDirectionChangeRequiringConfirmation(state, placement, isRecheck)) return false;
+
+ const now = Date.now();
+ if (state.autoAvoidPendingDirection !== placement.direction) {
+ state.autoAvoidPendingDirection = placement.direction;
+ state.autoAvoidPendingSince = now;
+ }
+ const elapsed = now - state.autoAvoidPendingSince;
+ if (elapsed >= AUTO_AVOID_DIRECTION_CONFIRM_DELAY) return false;
+
+ window.clearTimeout(state.autoAvoidDirectionConfirmTimer);
+ state.autoAvoidDirectionConfirmTimer = window.setTimeout(() => {
+ state.autoAvoidDirectionConfirmTimer = null;
+ if (state.hasButtonShown) confirmDirectionChange();
+ }, AUTO_AVOID_DIRECTION_CONFIRM_DELAY - elapsed);
+ return true;
+}
+
+export function clearPendingDirectionChange(state) {
+ window.clearTimeout(state.autoAvoidDirectionConfirmTimer);
+ state.autoAvoidDirectionConfirmTimer = null;
+ state.autoAvoidPendingDirection = null;
+ state.autoAvoidPendingSince = null;
+}
+
+function areButtonAnchorsEqual(first, second) {
+ if (!first || !second) return false;
+ if (first.selectionRect || second.selectionRect) {
+ if (!first.selectionRect || !second.selectionRect) return false;
+ return ["bottom", "left", "right", "top"].every(
+ (property) =>
+ Math.abs(first.selectionRect[property] - second.selectionRect[property]) <= 1
+ );
+ }
+ return Math.abs(first.x - second.x) <= 4 && Math.abs(first.y - second.y) <= 4;
+}
+
+function isDirectionChangeRequiringConfirmation(state, placement, isRecheck) {
+ return (
+ isRecheck &&
+ state.buttonPositionSetting === AUTO_AVOID_POSITION &&
+ Boolean(state.buttonPlacementDirection) &&
+ placement.direction !== state.buttonPlacementDirection
+ );
+}
diff --git a/packages/EdgeTranslate/src/content/select/select_helpers.js b/packages/EdgeTranslate/src/content/select/select_helpers.js
index 11953913..00e2809b 100644
--- a/packages/EdgeTranslate/src/content/select/select_helpers.js
+++ b/packages/EdgeTranslate/src/content/select/select_helpers.js
@@ -59,19 +59,6 @@ export function getInnerParent(container) {
return container.shadowRoot;
}
-export function getButtonPosition(positionSetting, container, event) {
- const offset = resolveButtonOffset(positionSetting, container);
- let left = event.x + offset.x;
- let top = event.y + offset.y;
- if (left <= 0 || left + container.clientWidth > window.innerWidth) {
- left = event.x - offset.x - container.clientWidth;
- }
- if (top <= 0 || top + container.clientHeight > window.innerHeight) {
- top = event.y - offset.y - container.clientHeight;
- }
- return { left, top };
-}
-
export function syncChangedSettings(state, changes, area, cancelLongPressSession) {
if (area !== "sync") return;
if (changes.LayoutSettings) {
@@ -115,22 +102,6 @@ export function applyButtonStyle(translationButton) {
});
}
-function resolveButtonOffset(position, container) {
- const offsetX = 10;
- const offsetY = 20;
- switch (position) {
- case "TopLeft":
- return { x: -offsetX - container.clientWidth, y: -offsetY - container.clientHeight };
- case "BottomRight":
- return { x: offsetX, y: offsetY };
- case "BottomLeft":
- return { x: -offsetX - container.clientWidth, y: offsetY };
- case "TopRight":
- default:
- return { x: offsetX, y: -offsetY - container.clientHeight };
- }
-}
-
function clickIfPresent(button) {
if (button !== null && button !== undefined) button.click();
}
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"
>
+