diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc40e3..f72c0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## [1.7.3] - 2026-04-17 + +### Added + +- Added support for Japanese (JA) localisation, expanding accessibility for Japanese-speaking users. +- Added a live distance preview in Measurement Mode, allowing users to see real-time measurements as they move the mouse after selecting the first element, enhancing usability and efficiency. +- Added dashed corner guidelines in Measurement Mode when hovering over the second element, providing better alignment context. When the second element is wider than the first, left and right edge gap measurements are also displayed for improved spatial understanding. + +### Changed + +- Shortened toggle switch labels to On/Off for a more compact UI. + ## [1.7.2] - 2026-04-09 ### Changed diff --git a/package.json b/package.json index f93e528..6813153 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "border-patrol", - "version": "1.7.2", + "version": "1.7.3", "description": "Are you tired of digging through complex CSS and hovering endlessly in DevTools just to figure out element boundaries, margins, and padding? **Border Patrol** is the free and open-source Chrome extension built to solve that frustration!", "main": "src/background.js", "scripts": { diff --git a/src/_locales/de/messages.json b/src/_locales/de/messages.json index 1673e49..e20c601 100644 --- a/src/_locales/de/messages.json +++ b/src/_locales/de/messages.json @@ -64,11 +64,11 @@ "description": "A double border style." }, "enabled": { - "message": "Aktiviert", + "message": "Ein", "description": "Indicates that a feature or option is turned on." }, "disabled": { - "message": "Deaktiviert", + "message": "Aus", "description": "Indicates that a feature or option is turned off." }, "enableOrDisableBorders": { diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 796e730..3460586 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -64,11 +64,11 @@ "description": "A double border style." }, "enabled": { - "message": "Enabled", + "message": "On", "description": "Indicates that a feature or option is turned on." }, "disabled": { - "message": "Disabled", + "message": "Off", "description": "Indicates that a feature or option is turned off." }, "enableOrDisableBorders": { diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json index 8650479..8468d35 100644 --- a/src/_locales/es/messages.json +++ b/src/_locales/es/messages.json @@ -64,11 +64,11 @@ "description": "A double border style." }, "enabled": { - "message": "Habilitado", + "message": "Activado", "description": "Indicates that a feature or option is turned on." }, "disabled": { - "message": "Deshabilitado", + "message": "Desactivado", "description": "Indicates that a feature or option is turned off." }, "enableOrDisableBorders": { diff --git a/src/_locales/ja/messages.json b/src/_locales/ja/messages.json new file mode 100644 index 0000000..0d9b6c6 --- /dev/null +++ b/src/_locales/ja/messages.json @@ -0,0 +1,182 @@ +{ + "extensionName": { + "message": "Border Patrol", + "description": "The brand name of the extension." + }, + "extensionLongName": { + "message": "Border Patrol – CSSデバッガー & 要素アウトライナー", + "description": "The long brand name of the extension." + }, + "extensionDescription": { + "message": "CSSボックスモデルデバッガー & 要素アウトライナー — UIの素早い検証、レイアウトの可視化、フロントエンド開発の効率化に。", + "description": "Extension description for manifest.json and Web Store SEO." + }, + "toggleBorderModeCommand": { + "message": "ボーダーモードを切り替える", + "description": "Command description for toggling Border Mode in manifest." + }, + "toggleInspectorModeCommand": { + "message": "インスペクターモードを切り替える", + "description": "Command description for toggling Inspector Mode in manifest." + }, + "borderSettings": { + "message": "ボーダー設定", + "description": "Title for the border settings section." + }, + "borderMode": { + "message": "ボーダーモード", + "description": "Label for selecting the border mode." + }, + "inspectorMode": { + "message": "インスペクターモード", + "description": "Label for toggling inspector mode." + }, + "borderSize": { + "message": "ボーダーサイズ", + "description": "Label for selecting the size of the border." + }, + "borderStyle": { + "message": "ボーダースタイル", + "description": "Label for selecting the style of the border." + }, + "size": { + "message": "サイズ", + "description": "Generic label for size." + }, + "style": { + "message": "スタイル", + "description": "Generic label for style." + }, + "solid": { + "message": "実線", + "description": "A solid border style." + }, + "dashed": { + "message": "破線", + "description": "A dashed border style." + }, + "dotted": { + "message": "点線", + "description": "A dotted border style." + }, + "double": { + "message": "二重線", + "description": "A double border style." + }, + "enabled": { + "message": "オン", + "description": "Indicates that a feature or option is turned on." + }, + "disabled": { + "message": "オフ", + "description": "Indicates that a feature or option is turned off." + }, + "enableOrDisableBorders": { + "message": "ボーダーの表示を切り替える", + "description": "Instruction to enable or disable borders." + }, + "enableOrDisableInspectors": { + "message": "インスペクターモードを切り替える", + "description": "Instruction to enable or disable inspectors." + }, + "error": { + "message": "エラー", + "description": "Indicates that an error has occurred." + }, + "restricted": { + "message": "制限済み", + "description": "Indicates that access is restricted." + }, + "restrictedDescription": { + "message": "このページは制限されています。", + "description": "Description shown when the extension is restricted from running on a page." + }, + "grantPermission": { + "message": "許可を付与", + "description": "Button text for granting permission to the extension." + }, + "capturing": { + "message": "キャプチャ中", + "description": "Indicates that the extension is currently capturing data." + }, + "takeScreenshot": { + "message": "スクリーンショットを撮る", + "description": "Button text for taking a screenshot." + }, + "screenshotModeVisible": { + "message": "表示範囲", + "description": "Segmented control option for capturing only the visible area of the page." + }, + "screenshotModeFullPage": { + "message": "全ページ", + "description": "Segmented control option for capturing the full scrollable page." + }, + "downloadPermissionRequired": { + "message": "スクリーンショットにはダウンロード許可が必要です", + "description": "Indicates that download permission is required to take screenshots." + }, + "downloadPermissionDenied": { + "message": "ダウンロード許可が拒否されました", + "description": "Indicates that download permission has been denied." + }, + "keyboardShortcuts": { + "message": "キーボードショートカット", + "description": "Title for the keyboard shortcuts section." + }, + "switchToDarkMode": { + "message": "ダークモードに切り替える", + "description": "Button text for switching to dark mode." + }, + "switchToLightMode": { + "message": "ライトモードに切り替える", + "description": "Button text for switching to light mode." + }, + "currentVersion": { + "message": "現在のバージョン: {version}", + "description": "Label for displaying the current version of the extension." + }, + "selectLanguage": { + "message": "拡張機能の言語を選択します。", + "description": "Select for changing the language of the extension." + }, + "none": { + "message": "なし", + "description": "Indicates that no option is selected." + }, + "measurementMode": { + "message": "計測モード", + "description": "Label for toggling measurement mode." + }, + "enableOrDisableMeasurement": { + "message": "計測モードを切り替える", + "description": "Instruction to enable or disable measurement mode." + }, + "measurementFirst": { + "message": "1番目", + "description": "Badge label for the first selected element in measurement mode." + }, + "measurementSecond": { + "message": "2番目", + "description": "Badge label for the second selected element in measurement mode." + }, + "toggleMeasurementModeCommand": { + "message": "計測モードを切り替える", + "description": "Command description for toggling Measurement Mode in manifest." + }, + "rulerMode": { + "message": "ルーラーモード", + "description": "Label for toggling ruler mode." + }, + "enableOrDisableRuler": { + "message": "ルーラーモードを切り替える", + "description": "Instruction to enable or disable ruler mode." + }, + "screenshotCaptureInProgress": { + "message": "スクリーンショットをキャプチャ中...", + "description": "Label shown in the page overlay while a full-page screenshot is being captured." + }, + "toggleRulerModeCommand": { + "message": "ルーラーモードを切り替える", + "description": "Command description for toggling Ruler Mode in manifest." + } +} diff --git a/src/manifest.json b/src/manifest.json index de3f39b..0c5cf16 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "__MSG_extensionLongName__", - "version": "1.7.2", + "version": "1.7.3", "description": "__MSG_extensionDescription__", "default_locale": "en", "permissions": [ diff --git a/src/popup/components/Footer.tsx b/src/popup/components/Footer.tsx index 0c3eb46..924a7bc 100644 --- a/src/popup/components/Footer.tsx +++ b/src/popup/components/Footer.tsx @@ -1,6 +1,6 @@ import { Layout, Select, Typography } from 'antd'; import DarkModeToggle from './DarkModeToggle'; -import { useLocaleContext } from '../context/LocaleContext'; +import { useLocaleContext, SUPPORTED_LOCALES } from '../context/LocaleContext'; import { useTranslation } from '../hooks/useTranslation'; import { LocaleCode } from '../../types/translations'; @@ -14,12 +14,10 @@ const footerStyle: React.CSSProperties = { width: '100%', }; -const localeOptions = [ - { value: 'en', label: 'EN' }, - { value: 'es', label: 'ES' }, - { value: 'fr', label: 'FR' }, - { value: 'de', label: 'DE' }, -]; +const localeOptions = SUPPORTED_LOCALES.map(code => ({ + value: code, + label: code.toUpperCase(), +})); // Placeholder for version, will be replaced during build const version = __BP_APP_VERSION__; diff --git a/src/popup/context/LocaleContext.tsx b/src/popup/context/LocaleContext.tsx index 876b6cd..ae91613 100644 --- a/src/popup/context/LocaleContext.tsx +++ b/src/popup/context/LocaleContext.tsx @@ -5,12 +5,13 @@ import { ILocaleContext, ILocaleProviderProps, } from '../../types/translations'; +import { localeMap } from '../locales'; /** Key used to store the user's preferred locale in Chrome storage. */ export const LOCALE_STORAGE_KEY = 'bp_user_locale'; -/** List of supported locales for the extension. */ -export const SUPPORTED_LOCALES: LocaleCode[] = ['en', 'es', 'fr', 'de']; +/** List of supported locales, derived from the locale map. */ +export const SUPPORTED_LOCALES = Object.keys(localeMap) as LocaleCode[]; // Create the context for locale management const LocaleContext = createContext({ diff --git a/src/popup/hooks/useTranslation.ts b/src/popup/hooks/useTranslation.ts index 73f9da2..95143e6 100644 --- a/src/popup/hooks/useTranslation.ts +++ b/src/popup/hooks/useTranslation.ts @@ -1,17 +1,6 @@ +import { localeMap } from '../locales'; import enMessages from '../../_locales/en/messages.json'; -import esMessages from '../../_locales/es/messages.json'; -import deMessages from '../../_locales/de/messages.json'; -import frMessages from '../../_locales/fr/messages.json'; import { useLocaleContext } from '../context/LocaleContext'; -import { LocaleCode, MessagesType } from '../../types/translations'; - -// Map of locale codes to their respective message objects -const localeMap: Record = { - en: enMessages, - es: esMessages, - fr: frMessages, - de: deMessages, -}; /** * Custom hook to provide translation functionality based on the current locale. diff --git a/src/popup/locales.ts b/src/popup/locales.ts new file mode 100644 index 0000000..d08a628 --- /dev/null +++ b/src/popup/locales.ts @@ -0,0 +1,14 @@ +import enMessages from '../_locales/en/messages.json'; +import esMessages from '../_locales/es/messages.json'; +import frMessages from '../_locales/fr/messages.json'; +import deMessages from '../_locales/de/messages.json'; +import jaMessages from '../_locales/ja/messages.json'; +import type { LocaleCode, MessagesType } from '../types/translations'; + +export const localeMap: Record = { + en: enMessages, + es: esMessages, + fr: frMessages, + de: deMessages, + ja: jaMessages, +}; diff --git a/src/scripts/measurement.ts b/src/scripts/measurement.ts index 70eac9a..261dedf 100644 --- a/src/scripts/measurement.ts +++ b/src/scripts/measurement.ts @@ -1,6 +1,15 @@ import Logger from './utils/logger'; import MEASUREMENT_STYLES from '../styles/components/measurement.shadow.scss'; import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; +import { MeasurementLabelRefs } from 'types/scripts/measurement'; +import { getEdgeData, EDGE_EPSILON } from './utils/measurement-geometry'; +import { + drawGuidelines, + drawLShaped, + drawHEdgeMisalign, + drawVEdgeMisalign, + drawSingleAxis, +} from './utils/measurement-svg'; (function () { let isMeasurementModeEnabled = false; @@ -13,7 +22,6 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; let hoveredElement: HTMLElement | null = null; let firstSelected: HTMLElement | null = null; let secondSelected: HTMLElement | null = null; - // Shadow DOM elements for selection highlights and connector let hoverHighlight: HTMLElement | null = null; let firstHighlight: HTMLElement | null = null; @@ -31,7 +39,6 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; const OUTLINE_COLOR = 'rgba(59, 130, 246, 0.8)'; const SELECTED_COLOR = 'rgba(16, 185, 129, 0.15)'; const SELECTED_OUTLINE = 'rgba(16, 185, 129, 0.8)'; - const CONNECTOR_COLOR = '#ef4444'; /** * Checks if an element belongs to the measurement UI itself. @@ -294,70 +301,12 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; label.style.display = 'block'; } - /** - * Returns the nearest edge anchor points between two DOMRects along with - * the individual horizontal and vertical gap distances between them. - * - * @param a - The first element's bounding rect. - * @param b - The second element's bounding rect. - * @returns Edge anchor points on each rect and the x/y gap values. - */ - function getEdgeData( - a: DOMRect, - b: DOMRect, - ): { - pointA: { x: number; y: number }; - pointB: { x: number; y: number }; - xGap: number; - yGap: number; - } { - // Horizontal component - let ax: number, bx: number, xGap: number; - if (a.right <= b.left) { - ax = a.right; - bx = b.left; - xGap = b.left - a.right; - } else if (b.right <= a.left) { - ax = a.left; - bx = b.right; - xGap = a.left - b.right; - } else { - const xMid = (Math.max(a.left, b.left) + Math.min(a.right, b.right)) / 2; - ax = xMid; - bx = xMid; - xGap = 0; - } - - // Vertical component - let ay: number, by: number, yGap: number; - if (a.bottom <= b.top) { - ay = a.bottom; - by = b.top; - yGap = b.top - a.bottom; - } else if (b.bottom <= a.top) { - ay = a.top; - by = b.bottom; - yGap = a.top - b.bottom; - } else { - const yMid = (Math.max(a.top, b.top) + Math.min(a.bottom, b.bottom)) / 2; - ay = yMid; - by = yMid; - yGap = 0; - } - - return { - pointA: { x: ax, y: ay }, - pointB: { x: bx, y: by }, - xGap, - yGap, - }; - } - /** Draws the SVG connector and distance label(s) between the two selected elements. */ - function drawConnector(): void { + function drawConnector(overrideSecond?: HTMLElement): void { + const second = overrideSecond ?? secondSelected; if ( !firstSelected || - !secondSelected || + !second || !connectorLine || !distanceLabel || !xDistanceLabel || @@ -366,13 +315,15 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; return; const rectA = firstSelected.getBoundingClientRect(); - const rectB = secondSelected.getBoundingClientRect(); + const rectB = second.getBoundingClientRect(); const { pointA: a, pointB: b, xGap, yGap } = getEdgeData(rectA, rectB); - - const svgNS = 'http://www.w3.org/2000/svg'; const svgEl = connectorLine as unknown as HTMLElement; + const labels: MeasurementLabelRefs = { + distanceLabel, + xDistanceLabel, + yDistanceLabel, + }; - // Reset all labels distanceLabel.style.display = 'none'; xDistanceLabel.style.display = 'none'; yDistanceLabel.style.display = 'none'; @@ -390,7 +341,6 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; return; } - // Size the SVG to cover the viewport svgEl.style.position = 'fixed'; svgEl.style.top = '0'; svgEl.style.left = '0'; @@ -402,97 +352,31 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; `0 0 ${window.innerWidth} ${window.innerHeight}`, ); - // Clear previous SVG content while (connectorLine.firstChild) { connectorLine.removeChild(connectorLine.firstChild); } + drawGuidelines(connectorLine, rectA); + drawGuidelines(connectorLine, rectB); + if (xGap > 0 && yGap > 0) { - // L-shaped connector: horizontal segment then vertical segment - // Corner sits at (b.x, a.y) — horizontal from A, then vertical to B - const corner = { x: b.x, y: a.y }; - - const drawSegment = ( - x1: number, - y1: number, - x2: number, - y2: number, - ): void => { - const seg = document.createElementNS(svgNS, 'line'); - seg.setAttribute('x1', String(x1)); - seg.setAttribute('y1', String(y1)); - seg.setAttribute('x2', String(x2)); - seg.setAttribute('y2', String(y2)); - seg.setAttribute('stroke', CONNECTOR_COLOR); - seg.setAttribute('stroke-width', '2'); - seg.setAttribute('stroke-dasharray', '6 4'); - connectorLine!.appendChild(seg); - }; - - // Horizontal: A edge → corner - drawSegment(a.x, a.y, corner.x, corner.y); - // Vertical: corner → B edge - drawSegment(corner.x, corner.y, b.x, b.y); - - // Endpoint circles at A and B - [a, b].forEach(point => { - const circle = document.createElementNS(svgNS, 'circle'); - circle.setAttribute('cx', String(point.x)); - circle.setAttribute('cy', String(point.y)); - circle.setAttribute('r', '4'); - circle.setAttribute('fill', CONNECTOR_COLOR); - connectorLine!.appendChild(circle); - }); - - // Small dot at the corner - const cornerDot = document.createElementNS(svgNS, 'circle'); - cornerDot.setAttribute('cx', String(corner.x)); - cornerDot.setAttribute('cy', String(corner.y)); - cornerDot.setAttribute('r', '3'); - cornerDot.setAttribute('fill', CONNECTOR_COLOR); - cornerDot.setAttribute('opacity', '0.5'); - connectorLine.appendChild(cornerDot); - - // X label centred on the horizontal segment - xDistanceLabel.textContent = `${Math.round(xGap)}px`; - xDistanceLabel.style.position = 'fixed'; - xDistanceLabel.style.left = `${(a.x + corner.x) / 2}px`; - xDistanceLabel.style.top = `${a.y}px`; - xDistanceLabel.style.display = 'block'; - - // Y label centred on the vertical segment - yDistanceLabel.textContent = `${Math.round(yGap)}px`; - yDistanceLabel.style.position = 'fixed'; - yDistanceLabel.style.left = `${corner.x}px`; - yDistanceLabel.style.top = `${(corner.y + b.y) / 2}px`; - yDistanceLabel.style.display = 'block'; + drawLShaped(connectorLine, labels, a, b, xGap, yGap); + } else if ( + xGap === 0 && + yGap > 0 && + (Math.abs(rectA.left - rectB.left) > EDGE_EPSILON || + Math.abs(rectA.right - rectB.right) > EDGE_EPSILON) + ) { + drawHEdgeMisalign(connectorLine, labels, rectA, rectB, a, b, yGap); + } else if ( + yGap === 0 && + xGap > 0 && + (Math.abs(rectA.top - rectB.top) > EDGE_EPSILON || + Math.abs(rectA.bottom - rectB.bottom) > EDGE_EPSILON) + ) { + drawVEdgeMisalign(connectorLine, labels, rectA, rectB, a, b, xGap); } else { - // Single-axis: one straight line with one label - const line = document.createElementNS(svgNS, 'line'); - line.setAttribute('x1', String(a.x)); - line.setAttribute('y1', String(a.y)); - line.setAttribute('x2', String(b.x)); - line.setAttribute('y2', String(b.y)); - line.setAttribute('stroke', CONNECTOR_COLOR); - line.setAttribute('stroke-width', '2'); - line.setAttribute('stroke-dasharray', '6 4'); - connectorLine.appendChild(line); - - [a, b].forEach(point => { - const circle = document.createElementNS(svgNS, 'circle'); - circle.setAttribute('cx', String(point.x)); - circle.setAttribute('cy', String(point.y)); - circle.setAttribute('r', '4'); - circle.setAttribute('fill', CONNECTOR_COLOR); - connectorLine!.appendChild(circle); - }); - - const dist = xGap > 0 ? Math.round(xGap) : Math.round(yGap); - distanceLabel.textContent = `${dist}px`; - distanceLabel.style.position = 'fixed'; - distanceLabel.style.left = `${(a.x + b.x) / 2}px`; - distanceLabel.style.top = `${(a.y + b.y) / 2}px`; - distanceLabel.style.display = 'block'; + drawSingleAxis(connectorLine, labels, a, b, xGap, yGap); } } @@ -522,6 +406,12 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; } if (firstSelected && secondSelected) { drawConnector(); + } else if (firstSelected && !secondSelected && hoveredElement) { + if (secondHighlight) + positionHighlight(secondHighlight, hoveredElement, false); + if (secondBadge) positionBadge(secondBadge, hoveredElement); + if (secondSizeLabel) positionSizeLabel(secondSizeLabel, hoveredElement); + drawConnector(hoveredElement); } } @@ -554,6 +444,16 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; if (target === firstSelected || target === secondSelected) return; hoveredElement = target; + + // After first selection, show a live preview of the measurement + if (firstSelected && !secondSelected && target !== firstSelected) { + if (secondHighlight) positionHighlight(secondHighlight, target, false); + if (secondBadge) positionBadge(secondBadge, target); + if (secondSizeLabel) positionSizeLabel(secondSizeLabel, target); + drawConnector(target); + return; + } + if (hoverHighlight) { positionHighlight(hoverHighlight, target, false); } @@ -563,6 +463,18 @@ import { RUNTIME_MESSAGES, RuntimeMessage } from 'types/runtime-messages'; function mouseOutHandler(): void { hoveredElement = null; if (hoverHighlight) hoverHighlight.style.display = 'none'; + + // Hide preview elements when mousing out without a confirmed second selection + if (firstSelected && !secondSelected) { + if (secondHighlight) secondHighlight.style.display = 'none'; + if (secondBadge) secondBadge.style.display = 'none'; + if (secondSizeLabel) secondSizeLabel.style.display = 'none'; + if (connectorLine) + (connectorLine as unknown as HTMLElement).style.display = 'none'; + if (distanceLabel) distanceLabel.style.display = 'none'; + if (xDistanceLabel) xDistanceLabel.style.display = 'none'; + if (yDistanceLabel) yDistanceLabel.style.display = 'none'; + } } /** diff --git a/src/scripts/utils/measurement-geometry.ts b/src/scripts/utils/measurement-geometry.ts new file mode 100644 index 0000000..f8a1fa5 --- /dev/null +++ b/src/scripts/utils/measurement-geometry.ts @@ -0,0 +1,58 @@ +import { EdgeData } from 'types/scripts/measurement'; + +/** + * Minimum pixel difference required before an edge misalignment is rendered. + * Used to suppress noise from sub-pixel layout differences. + */ +export const EDGE_EPSILON = 1; + +/** + * Returns the nearest edge anchor points between two DOMRects along with + * the individual horizontal and vertical gap distances between them. + * + * @param a - The first element's bounding rect. + * @param b - The second element's bounding rect. + * @returns Edge anchor points on each rect and the x/y gap values. + */ +export function getEdgeData(a: DOMRect, b: DOMRect): EdgeData { + // Horizontal component + let ax: number, bx: number, xGap: number; + if (a.right <= b.left) { + ax = a.right; + bx = b.left; + xGap = b.left - a.right; + } else if (b.right <= a.left) { + ax = a.left; + bx = b.right; + xGap = a.left - b.right; + } else { + const xMid = (Math.max(a.left, b.left) + Math.min(a.right, b.right)) / 2; + ax = xMid; + bx = xMid; + xGap = 0; + } + + // Vertical component + let ay: number, by: number, yGap: number; + if (a.bottom <= b.top) { + ay = a.bottom; + by = b.top; + yGap = b.top - a.bottom; + } else if (b.bottom <= a.top) { + ay = a.top; + by = b.bottom; + yGap = a.top - b.bottom; + } else { + const yMid = (Math.max(a.top, b.top) + Math.min(a.bottom, b.bottom)) / 2; + ay = yMid; + by = yMid; + yGap = 0; + } + + return { + pointA: { x: ax, y: ay }, + pointB: { x: bx, y: by }, + xGap, + yGap, + }; +} diff --git a/src/scripts/utils/measurement-svg.ts b/src/scripts/utils/measurement-svg.ts new file mode 100644 index 0000000..49c7758 --- /dev/null +++ b/src/scripts/utils/measurement-svg.ts @@ -0,0 +1,309 @@ +import { Point, MeasurementLabelRefs } from 'types/scripts/measurement'; +import { EDGE_EPSILON } from './measurement-geometry'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +export const CONNECTOR_COLOR = '#ef4444'; +export const GUIDELINE_COLOR = 'rgba(148, 163, 184, 0.3)'; + +// --------------------------------------------------------------------------- +// SVG primitive helpers +// --------------------------------------------------------------------------- + +/** + * Appends a dashed connector line between two points to the given SVG element. + * + * @param svg - The SVG element to append to. + * @param x1 - Start x coordinate. + * @param y1 - Start y coordinate. + * @param x2 - End x coordinate. + * @param y2 - End y coordinate. + */ +export function appendConnectorLine( + svg: SVGElement, + x1: number, + y1: number, + x2: number, + y2: number, +): void { + const el = document.createElementNS(SVG_NS, 'line'); + el.setAttribute('x1', String(x1)); + el.setAttribute('y1', String(y1)); + el.setAttribute('x2', String(x2)); + el.setAttribute('y2', String(y2)); + el.setAttribute('stroke', CONNECTOR_COLOR); + el.setAttribute('stroke-width', '2'); + el.setAttribute('stroke-dasharray', '6 4'); + svg.appendChild(el); +} + +/** + * Appends a filled circle endpoint to the given SVG element. + * + * @param svg - The SVG element to append to. + * @param cx - Centre x coordinate. + * @param cy - Centre y coordinate. + * @param r - Radius (default 4). + * @param opacity - Optional opacity value as a string. + */ +export function appendConnectorDot( + svg: SVGElement, + cx: number, + cy: number, + r = 4, + opacity?: string, +): void { + const el = document.createElementNS(SVG_NS, 'circle'); + el.setAttribute('cx', String(cx)); + el.setAttribute('cy', String(cy)); + el.setAttribute('r', String(r)); + el.setAttribute('fill', CONNECTOR_COLOR); + if (opacity !== undefined) el.setAttribute('opacity', opacity); + svg.appendChild(el); +} + +// --------------------------------------------------------------------------- +// Connector branch functions — each handles one measurement layout +// --------------------------------------------------------------------------- + +/** + * Draws thin dashed guideline extensions from each corner of the given rect + * to the viewport edges. Call for both elements so connector lines always + * originate from a visible guideline. + * + * @param svg - The SVG overlay element to draw into. + * @param rect - The bounding rect of the element to draw guidelines for. + */ +export function drawGuidelines(svg: SVGElement, rect: DOMRect): void { + const vw = window.innerWidth; + const vh = window.innerHeight; + + const segments: [number, number, number, number][] = [ + [0, rect.top, rect.left, rect.top], + [rect.left, 0, rect.left, rect.top], + [rect.right, rect.top, vw, rect.top], + [rect.right, 0, rect.right, rect.top], + [0, rect.bottom, rect.left, rect.bottom], + [rect.left, rect.bottom, rect.left, vh], + [rect.right, rect.bottom, vw, rect.bottom], + [rect.right, rect.bottom, rect.right, vh], + ]; + + segments.forEach(([x1, y1, x2, y2]) => { + const line = document.createElementNS(SVG_NS, 'line'); + line.setAttribute('x1', String(x1)); + line.setAttribute('y1', String(y1)); + line.setAttribute('x2', String(x2)); + line.setAttribute('y2', String(y2)); + line.setAttribute('stroke', GUIDELINE_COLOR); + line.setAttribute('stroke-width', '1'); + line.setAttribute('stroke-dasharray', '4 4'); + svg.appendChild(line); + }); +} + +/** + * L-shaped connector for elements offset on both axes. + * + * @param svg - The SVG overlay element. + * @param labels - Distance label DOM references. + * @param a - Anchor point on the first element. + * @param b - Anchor point on the second element. + * @param xGap - Horizontal gap in pixels. + * @param yGap - Vertical gap in pixels. + */ +export function drawLShaped( + svg: SVGElement, + labels: MeasurementLabelRefs, + a: Point, + b: Point, + xGap: number, + yGap: number, +): void { + const corner = { x: b.x, y: a.y }; + appendConnectorLine(svg, a.x, a.y, corner.x, corner.y); + appendConnectorLine(svg, corner.x, corner.y, b.x, b.y); + appendConnectorDot(svg, a.x, a.y); + appendConnectorDot(svg, b.x, b.y); + appendConnectorDot(svg, corner.x, corner.y, 3, '0.5'); + + const { xDistanceLabel, yDistanceLabel } = labels; + + xDistanceLabel.textContent = `${Math.round(xGap)}px`; + xDistanceLabel.style.position = 'fixed'; + xDistanceLabel.style.left = `${(a.x + corner.x) / 2}px`; + xDistanceLabel.style.top = `${a.y}px`; + xDistanceLabel.style.display = 'block'; + + yDistanceLabel.textContent = `${Math.round(yGap)}px`; + yDistanceLabel.style.position = 'fixed'; + yDistanceLabel.style.left = `${corner.x}px`; + yDistanceLabel.style.top = `${(corner.y + b.y) / 2}px`; + yDistanceLabel.style.display = 'block'; +} + +/** + * Horizontal edge-misalignment layout: elements are vertically separated with + * horizontal overlap but misaligned left/right edges. Renders the y-gap line + * plus horizontal segments for each edge difference exceeding EDGE_EPSILON. + * + * @param svg - The SVG overlay element. + * @param labels - Distance label DOM references. + * @param rectA - Bounding rect of the first element. + * @param rectB - Bounding rect of the second element. + * @param a - Anchor point on the first element. + * @param b - Anchor point on the second element. + * @param yGap - Vertical gap in pixels. + */ +export function drawHEdgeMisalign( + svg: SVGElement, + labels: MeasurementLabelRefs, + rectA: DOMRect, + rectB: DOMRect, + a: Point, + b: Point, + yGap: number, +): void { + const leftEdgeDiff = Math.abs(rectA.left - rectB.left); + const rightEdgeDiff = Math.abs(rectA.right - rectB.right); + const yBetween = (a.y + b.y) / 2; + const rectACenterY = rectA.top + rectA.height / 2; + const rectBCenterY = rectB.top + rectB.height / 2; + const leftGapY = rectA.left > rectB.left ? rectACenterY : rectBCenterY; + const rightGapY = rectA.right < rectB.right ? rectACenterY : rectBCenterY; + + const { distanceLabel, xDistanceLabel, yDistanceLabel } = labels; + + appendConnectorLine(svg, a.x, a.y, a.x, b.y); + appendConnectorDot(svg, a.x, a.y); + appendConnectorDot(svg, a.x, b.y); + + yDistanceLabel.textContent = `${Math.round(yGap)}px`; + yDistanceLabel.style.position = 'fixed'; + yDistanceLabel.style.left = `${a.x}px`; + yDistanceLabel.style.top = `${yBetween}px`; + yDistanceLabel.style.display = 'block'; + + if (leftEdgeDiff > EDGE_EPSILON) { + const leftFrom = Math.min(rectA.left, rectB.left); + const leftTo = Math.max(rectA.left, rectB.left); + appendConnectorLine(svg, leftFrom, leftGapY, leftTo, leftGapY); + appendConnectorDot(svg, leftFrom, leftGapY); + appendConnectorDot(svg, leftTo, leftGapY); + xDistanceLabel.textContent = `${Math.round(leftEdgeDiff)}px`; + xDistanceLabel.style.position = 'fixed'; + xDistanceLabel.style.left = `${(leftFrom + leftTo) / 2}px`; + xDistanceLabel.style.top = `${leftGapY}px`; + xDistanceLabel.style.display = 'block'; + } + + if (rightEdgeDiff > EDGE_EPSILON) { + const rightFrom = Math.min(rectA.right, rectB.right); + const rightTo = Math.max(rectA.right, rectB.right); + appendConnectorLine(svg, rightFrom, rightGapY, rightTo, rightGapY); + appendConnectorDot(svg, rightFrom, rightGapY); + appendConnectorDot(svg, rightTo, rightGapY); + distanceLabel.textContent = `${Math.round(rightEdgeDiff)}px`; + distanceLabel.style.position = 'fixed'; + distanceLabel.style.left = `${(rightFrom + rightTo) / 2}px`; + distanceLabel.style.top = `${rightGapY}px`; + distanceLabel.style.display = 'block'; + } +} + +/** + * Vertical edge-misalignment layout: elements are horizontally separated with + * vertical overlap but misaligned top/bottom edges. Renders the x-gap line + * plus vertical segments for each edge difference exceeding EDGE_EPSILON. + * + * @param svg - The SVG overlay element. + * @param labels - Distance label DOM references. + * @param rectA - Bounding rect of the first element. + * @param rectB - Bounding rect of the second element. + * @param a - Anchor point on the first element. + * @param b - Anchor point on the second element. + * @param xGap - Horizontal gap in pixels. + */ +export function drawVEdgeMisalign( + svg: SVGElement, + labels: MeasurementLabelRefs, + rectA: DOMRect, + rectB: DOMRect, + a: Point, + b: Point, + xGap: number, +): void { + const topEdgeDiff = Math.abs(rectA.top - rectB.top); + const bottomEdgeDiff = Math.abs(rectA.bottom - rectB.bottom); + const xBetween = (a.x + b.x) / 2; + const rectACenterX = rectA.left + rectA.width / 2; + const rectBCenterX = rectB.left + rectB.width / 2; + const topGapX = rectA.top > rectB.top ? rectACenterX : rectBCenterX; + const bottomGapX = rectA.bottom < rectB.bottom ? rectACenterX : rectBCenterX; + + const { distanceLabel, xDistanceLabel, yDistanceLabel } = labels; + + appendConnectorLine(svg, a.x, a.y, b.x, b.y); + appendConnectorDot(svg, a.x, a.y); + appendConnectorDot(svg, b.x, b.y); + + distanceLabel.textContent = `${Math.round(xGap)}px`; + distanceLabel.style.position = 'fixed'; + distanceLabel.style.left = `${xBetween}px`; + distanceLabel.style.top = `${a.y}px`; + distanceLabel.style.display = 'block'; + + if (topEdgeDiff > EDGE_EPSILON) { + const topFrom = Math.min(rectA.top, rectB.top); + const topTo = Math.max(rectA.top, rectB.top); + appendConnectorLine(svg, topGapX, topFrom, topGapX, topTo); + appendConnectorDot(svg, topGapX, topFrom); + appendConnectorDot(svg, topGapX, topTo); + xDistanceLabel.textContent = `${Math.round(topEdgeDiff)}px`; + xDistanceLabel.style.position = 'fixed'; + xDistanceLabel.style.left = `${topGapX}px`; + xDistanceLabel.style.top = `${(topFrom + topTo) / 2}px`; + xDistanceLabel.style.display = 'block'; + } + + if (bottomEdgeDiff > EDGE_EPSILON) { + const bottomFrom = Math.min(rectA.bottom, rectB.bottom); + const bottomTo = Math.max(rectA.bottom, rectB.bottom); + appendConnectorLine(svg, bottomGapX, bottomFrom, bottomGapX, bottomTo); + appendConnectorDot(svg, bottomGapX, bottomFrom); + appendConnectorDot(svg, bottomGapX, bottomTo); + yDistanceLabel.textContent = `${Math.round(bottomEdgeDiff)}px`; + yDistanceLabel.style.position = 'fixed'; + yDistanceLabel.style.left = `${bottomGapX}px`; + yDistanceLabel.style.top = `${(bottomFrom + bottomTo) / 2}px`; + yDistanceLabel.style.display = 'block'; + } +} + +/** + * Single dashed line with one distance label for pure x or y separation. + * + * @param svg - The SVG overlay element. + * @param labels - Distance label DOM references. + * @param a - Anchor point on the first element. + * @param b - Anchor point on the second element. + * @param xGap - Horizontal gap in pixels. + * @param yGap - Vertical gap in pixels. + */ +export function drawSingleAxis( + svg: SVGElement, + labels: MeasurementLabelRefs, + a: Point, + b: Point, + xGap: number, + yGap: number, +): void { + appendConnectorLine(svg, a.x, a.y, b.x, b.y); + appendConnectorDot(svg, a.x, a.y); + appendConnectorDot(svg, b.x, b.y); + const dist = xGap > 0 ? Math.round(xGap) : Math.round(yGap); + labels.distanceLabel.textContent = `${dist}px`; + labels.distanceLabel.style.position = 'fixed'; + labels.distanceLabel.style.left = `${(a.x + b.x) / 2}px`; + labels.distanceLabel.style.top = `${(a.y + b.y) / 2}px`; + labels.distanceLabel.style.display = 'block'; +} diff --git a/src/types/scripts/measurement.d.ts b/src/types/scripts/measurement.d.ts new file mode 100644 index 0000000..b0cfd23 --- /dev/null +++ b/src/types/scripts/measurement.d.ts @@ -0,0 +1,35 @@ +/** + * A 2D point in viewport coordinates. + */ +export type Point = { + x: number; + y: number; +}; + +/** + * Computed gap distances and nearest edge anchor points between two elements. + * + * @property pointA - Anchor point on the first element's nearest edge. + * @property pointB - Anchor point on the second element's nearest edge. + * @property xGap - Horizontal gap in pixels (0 if elements overlap horizontally). + * @property yGap - Vertical gap in pixels (0 if elements overlap vertically). + */ +export type EdgeData = { + pointA: Point; + pointB: Point; + xGap: number; + yGap: number; +}; + +/** + * References to the three distance label DOM elements used by the connector. + * + * @property distanceLabel - Label for single-axis distance or right edge gap. + * @property xDistanceLabel - Label for x-distance, left edge gap, or top edge gap. + * @property yDistanceLabel - Label for y-distance or bottom edge gap. + */ +export type MeasurementLabelRefs = { + distanceLabel: HTMLElement; + xDistanceLabel: HTMLElement; + yDistanceLabel: HTMLElement; +}; diff --git a/src/types/translations.d.ts b/src/types/translations.d.ts index ec223fa..dec436f 100644 --- a/src/types/translations.d.ts +++ b/src/types/translations.d.ts @@ -1,5 +1,5 @@ /** Locale codes supported by the extension. */ -export type LocaleCode = 'en' | 'es' | 'fr' | 'de'; +export type LocaleCode = 'en' | 'es' | 'fr' | 'de' | 'ja'; /** Type definition for translation messages. */ export type MessagesType = { diff --git a/tests/src/popup/hooks/useTranslation.test.ts b/tests/src/popup/hooks/useTranslation.test.ts new file mode 100644 index 0000000..d02c13c --- /dev/null +++ b/tests/src/popup/hooks/useTranslation.test.ts @@ -0,0 +1,62 @@ +import type { LocaleCode } from 'types/translations'; + +jest.mock('popup/context/LocaleContext', () => ({ + useLocaleContext: jest.fn(), +})); + +import { useLocaleContext } from 'popup/context/LocaleContext'; +import { useTranslation } from 'popup/hooks/useTranslation'; + +const mockLocale = (locale: LocaleCode) => { + (useLocaleContext as jest.Mock).mockReturnValue({ locale, changeLocale: jest.fn() }); +}; + +describe('useTranslation', () => { + it('returns English translations when locale is en', () => { + mockLocale('en'); + const { translate } = useTranslation(); + expect(translate('enabled')).toBe('On'); + expect(translate('disabled')).toBe('Off'); + }); + + it('returns Japanese translations when locale is ja', () => { + mockLocale('ja'); + const { translate } = useTranslation(); + expect(translate('enabled')).toBe('オン'); + expect(translate('disabled')).toBe('オフ'); + }); + + it('returns German translations when locale is de', () => { + mockLocale('de'); + const { translate } = useTranslation(); + expect(translate('enabled')).toBe('Ein'); + expect(translate('disabled')).toBe('Aus'); + }); + + it('returns Spanish translations when locale is es', () => { + mockLocale('es'); + const { translate } = useTranslation(); + expect(translate('enabled')).toBe('Activado'); + expect(translate('disabled')).toBe('Desactivado'); + }); + + it('returns French translations when locale is fr', () => { + mockLocale('fr'); + const { translate } = useTranslation(); + expect(translate('enabled')).toBe('Activé'); + expect(translate('disabled')).toBe('Désactivé'); + }); + + it('falls back to the key name for unknown keys', () => { + mockLocale('en'); + const { translate } = useTranslation(); + expect(translate('nonExistentKey')).toBe('nonExistentKey'); + }); + + it('interpolates variables into the message', () => { + mockLocale('en'); + const { translate } = useTranslation(); + const result = translate('currentVersion', { version: '2.0.0' }); + expect(result).toContain('2.0.0'); + }); +}); diff --git a/tests/src/popup/locales.test.ts b/tests/src/popup/locales.test.ts new file mode 100644 index 0000000..4f52a60 --- /dev/null +++ b/tests/src/popup/locales.test.ts @@ -0,0 +1,31 @@ +import { localeMap } from 'popup/locales'; + +const locales = Object.keys(localeMap) as (keyof typeof localeMap)[]; +const enKeys = Object.keys(localeMap.en); + +describe('localeMap', () => { + it('includes English', () => { + expect(localeMap).toHaveProperty('en'); + }); + + it('contains all expected locales', () => { + expect(locales).toEqual(expect.arrayContaining(['en', 'es', 'fr', 'de', 'ja'])); + }); + + locales.forEach(locale => { + describe(`locale: ${locale}`, () => { + it('has all English translation keys', () => { + const localeKeys = Object.keys(localeMap[locale]); + enKeys.forEach(key => { + expect(localeKeys).toContain(key); + }); + }); + + it('has no empty message values', () => { + Object.entries(localeMap[locale]).forEach(([, entry]) => { + expect(entry.message.trim()).not.toBe(''); + }); + }); + }); + }); +});