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" > +