import type { OverlayContentMeasurement, OverlayContentRect } from '../types'; import type { RendererContext } from './context'; const MEASUREMENT_DEBOUNCE_MS = 80; function isMeasurableOverlayLayer(layer: string): layer is 'visible' { return layer === 'visible'; } function round2(value: number): number { return Math.round(value * 100) / 100; } function toMeasuredRect(rect: DOMRect): OverlayContentRect | null { if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) { return null; } if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) { return null; } const width = Math.max(0, rect.width); const height = Math.max(0, rect.height); return { x: round2(rect.left), y: round2(rect.top), width: round2(width), height: round2(height), }; } function unionRects(a: OverlayContentRect, b: OverlayContentRect): OverlayContentRect { const left = Math.min(a.x, b.x); const top = Math.min(a.y, b.y); const right = Math.max(a.x + a.width, b.x + b.width); const bottom = Math.max(a.y + a.height, b.y + b.height); return { x: round2(left), y: round2(top), width: round2(Math.max(0, right - left)), height: round2(Math.max(0, bottom - top)), }; } function hasVisibleTextContent(element: HTMLElement): boolean { return Boolean(element.textContent && element.textContent.trim().length > 0); } function hasArea(rect: OverlayContentRect): boolean { return rect.width > 0 && rect.height > 0; } function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] { const rects: OverlayContentRect[] = []; const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot); if (subtitleHasContent) { const subtitleRect = toMeasuredRect(ctx.dom.subtitleContainer.getBoundingClientRect()); if (subtitleRect && hasArea(subtitleRect)) { rects.push(subtitleRect); } } const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot); if (secondaryHasContent) { const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect()); if (secondaryRect && hasArea(secondaryRect)) { rects.push(secondaryRect); } } if (ctx.state?.subtitleSidebarModalOpen) { const sidebarRect = toMeasuredRect(ctx.dom.subtitleSidebarContent.getBoundingClientRect()); if (sidebarRect && hasArea(sidebarRect)) { rects.push(sidebarRect); } } if (ctx.dom.overlayNotificationStack?.children.length > 0) { const notificationRect = toMeasuredRect( ctx.dom.overlayNotificationStack.getBoundingClientRect(), ); if (notificationRect && hasArea(notificationRect)) { rects.push(notificationRect); } } if (ctx.state?.notificationHistoryOpen) { const historyRect = toMeasuredRect(ctx.dom.overlayNotificationHistory.getBoundingClientRect()); if (historyRect && hasArea(historyRect)) { rects.push(historyRect); } } return rects; } function collectContentRect(rects: OverlayContentRect[]): OverlayContentRect | null { let combinedRect: OverlayContentRect | null = null; for (const rect of rects) { combinedRect = combinedRect ? unionRects(combinedRect, rect) : rect; } if (!combinedRect) { return null; } return { x: combinedRect.x, y: combinedRect.y, width: round2(Math.max(0, combinedRect.width)), height: round2(Math.max(0, combinedRect.height)), }; } export function createOverlayContentMeasurementReporter(ctx: RendererContext) { let debounceTimer: number | null = null; function emitNow(): void { if (!isMeasurableOverlayLayer(ctx.platform.overlayLayer)) { return; } const interactiveRects = collectInteractiveRects(ctx); const measurement: OverlayContentMeasurement = { layer: ctx.platform.overlayLayer, measuredAtMs: Date.now(), viewport: { width: window.innerWidth, height: window.innerHeight, }, // Explicit null rect signals "no content yet", and main should use fallback bounds. contentRect: collectContentRect(interactiveRects), interactiveRects, }; window.electronAPI.reportOverlayContentBounds(measurement); } function schedule(): void { if (debounceTimer !== null) { window.clearTimeout(debounceTimer); } debounceTimer = window.setTimeout(() => { debounceTimer = null; emitNow(); }, MEASUREMENT_DEBOUNCE_MS); } return { emitNow, schedule, }; }