Co-Study4Grid / frontend /src /hooks /useOverflowIframe.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
18.2 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
import React from 'react';
import type {
ActionOverviewFilters,
IframeToParentMessage,
ParentToIframeMessage,
} from '../types';
import { interactionLogger } from '../utils/interactionLogger';
import { decidePopoverPlacement } from '../utils/popoverPlacement';
import type { OverflowPin } from '../utils/svg/overflowPinPayload';
/**
* Runtime guard for the iframe → parent envelope. Verifies the shape
* against the discriminated union in `types.ts` so the consuming
* branches can rely on typed access.
*/
function isIframeMessage(value: unknown): value is IframeToParentMessage {
if (!value || typeof value !== 'object') return false;
const t = (value as { type?: unknown }).type;
if (typeof t !== 'string' || !t.startsWith('cs4g:')) return false;
return true;
}
export interface OverflowIframePopoverPin {
id: string;
screenX: number;
screenY: number;
placeAbove: boolean;
horizontalAlign: 'start' | 'center' | 'end';
}
export interface OverflowIframeState {
overflowIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
overflowPopoverRef: React.MutableRefObject<HTMLDivElement | null>;
overflowPopoverPin: OverflowIframePopoverPin | null;
setOverflowPopoverPin: React.Dispatch<React.SetStateAction<OverflowIframePopoverPin | null>>;
overflowPopoverViewport: { width: number; height: number } | null;
setOverflowPopoverViewport: React.Dispatch<React.SetStateAction<{ width: number; height: number } | null>>;
}
interface UseOverflowIframeArgs {
pdfUrl: string | null | undefined;
overflowPinsEnabled: boolean;
overflowPins: ReadonlyArray<OverflowPin> | undefined;
overviewFilters: ActionOverviewFilters | undefined;
/** Total execution time (seconds) for creating + generating the
* overflow analysis graph. Forwarded to the iframe via the
* ``cs4g:overflow-meta`` envelope so the overlay can render it
* as a subtitle below its ``<h1>`` filename header. ``null`` /
* ``undefined`` hides the subtitle. */
overflowGraphTime?: number | null;
onOverflowPinPreview?: (actionId: string) => void;
onOverflowPinDoubleClick?: (actionId: string, substation: string) => void;
onSimulateUnsimulatedAction?: (actionId: string) => void;
onOverviewFiltersChange?: (filters: ActionOverviewFilters) => void;
/** The iframe sidebar hosts the pins on/off toggle; the parent
* remains the source of truth for `overflowPinsEnabled` so it
* echoes the new state back via `cs4g:pins`. */
onOverflowPinsToggle?: (enabled: boolean) => void;
onVlOpen?: (vlId: string) => void;
/** When the overflow tab is detached into a secondary popup, the
* iframe's `window.parent.postMessage(...)` calls land on the
* popup window — not the main React window where this hook lives.
* Pass the popup window here so the message handler is also bound
* to it; without this, every iframe→parent event (overlay-ready,
* pin clicks, filter changes, …) is dropped after a layout switch
* remounts the iframe inside the popup. */
detachedWindow?: Window | null;
}
/**
* Encapsulates the overflow-graph iframe ↔ parent-window plumbing:
*
* 1. ``cs4g:overlay-ready`` handshake so we know the iframe has
* registered its message listener before the first pin payload
* is broadcast.
* 2. Routing every iframe-originated message to the right parent
* callback (pin click, pin double-click, unsimulated-pin
* double-click, filter change, layer toggle, node double-click)
* and emitting the matching interactionLogger event.
* 3. Pin-popover state (`overflowPopoverPin` / `overflowPopoverViewport`)
* with outside-click + Escape dismissal, mirroring the Action-
* Overview pin preview UX.
* 4. Broadcasting current pin payload + filter chip state back into
* the iframe whenever they change (or once at overlay-ready time).
*
* The iframe is same-origin (localhost:8000), so postMessage is safe.
*/
export function useOverflowIframe(args: UseOverflowIframeArgs): OverflowIframeState {
const {
pdfUrl,
overflowPinsEnabled,
overflowPins,
overviewFilters,
overflowGraphTime,
onOverflowPinPreview,
onOverflowPinDoubleClick,
onSimulateUnsimulatedAction,
onOverviewFiltersChange,
onOverflowPinsToggle,
onVlOpen,
detachedWindow,
} = args;
const overflowIframeRef = React.useRef<HTMLIFrameElement | null>(null);
const [overlayReady, setOverlayReady] = React.useState(false);
const [overflowPopoverPin, setOverflowPopoverPin] = React.useState<OverflowIframePopoverPin | null>(null);
const [overflowPopoverViewport, setOverflowPopoverViewport] = React.useState<{
width: number; height: number;
} | null>(null);
const overflowPopoverRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
// Reset readiness on URL change (a new file means a fresh load).
setOverlayReady(false);
// A fresh iframe means any old popover is referencing a pin
// that no longer exists. Drop it on URL change.
setOverflowPopoverPin(null);
setOverflowPopoverViewport(null);
}, [pdfUrl]);
React.useEffect(() => {
const onMessage = (ev: MessageEvent) => {
if (!isIframeMessage(ev?.data)) return;
const msg = ev.data;
switch (msg.type) {
case 'cs4g:overlay-ready':
setOverlayReady(true);
break;
case 'cs4g:pin-clicked': {
// Single click on an overflow pin mirrors the
// Action-Overview pin: focus the feed card on the
// matching action AND show a floating
// ActionCardPopover anchored on the pin. Must NOT
// call `onActionSelect` — that would switch the
// active tab and prevent a follow-up double-click
// drill into the SLD overlay.
if (onOverflowPinPreview) onOverflowPinPreview(msg.actionId);
interactionLogger.record('overflow_pin_clicked', { actionId: msg.actionId });
// The pin's bounding rect is in iframe-screen
// pixels; translate to parent-screen pixels by
// adding the iframe's own offset so the popover
// lands on the correct spot in the parent document.
const iframeEl = overflowIframeRef.current;
const rect = msg.screenRect;
if (iframeEl && rect) {
const ifRect = iframeEl.getBoundingClientRect();
const cx = ifRect.left + rect.left + rect.width / 2;
const cy = ifRect.top + rect.top + rect.height / 2;
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
};
const placement = decidePopoverPlacement(cx, cy, viewport);
setOverflowPopoverPin({
id: msg.actionId,
screenX: cx,
screenY: cy,
...placement,
});
setOverflowPopoverViewport(viewport);
}
break;
}
case 'cs4g:pin-double-clicked':
// Drill into the SLD on the action sub-tab for
// that substation. Double-click cancels any open
// preview popover so the SLD overlay takes focus.
setOverflowPopoverPin(null);
setOverflowPopoverViewport(null);
if (onOverflowPinDoubleClick) {
onOverflowPinDoubleClick(msg.actionId, msg.substation);
}
break;
case 'cs4g:overflow-unsimulated-pin-double-clicked':
// Double-click on an un-simulated overflow pin
// kicks off a manual simulation — same path the
// Action Overview's renderUnsimulatedPin double-
// click takes through
// `handleSimulateUnsimulatedAction`. Drop the
// popover first; the simulation will replace
// this pin with a real one.
setOverflowPopoverPin(null);
setOverflowPopoverViewport(null);
if (onSimulateUnsimulatedAction) {
onSimulateUnsimulatedAction(msg.actionId);
}
interactionLogger.record('overview_unsimulated_pin_simulated', {
action_id: msg.actionId,
});
break;
case 'cs4g:overflow-filter-changed':
// The iframe sidebar carries a copy of the
// Action-Overview filter chips. Forward changes
// so the parent's shared `overviewFilters` (which
// also drives the Action Feed and the Action
// Overview NAD pins) stays in lock-step.
if (onOverviewFiltersChange) {
onOverviewFiltersChange(msg.filters);
}
interactionLogger.record('overview_filter_changed', {
kind: 'overflow_iframe',
});
break;
case 'cs4g:overflow-layer-toggled':
interactionLogger.record('overflow_layer_toggled', {
key: msg.key, label: msg.label, visible: msg.visible,
});
break;
case 'cs4g:overflow-select-all-layers':
interactionLogger.record('overflow_select_all_layers', {
visible: msg.visible,
});
break;
case 'cs4g:overflow-pins-toggled':
// The pins on/off toggle now lives inside the
// iframe's filter panel. Forward the new state so
// the parent's `overflowPinsEnabled` flips and the
// standard `cs4g:pins` echo brings the iframe UI
// back in sync (the parent remains the source of
// truth, single round-trip per toggle).
if (onOverflowPinsToggle) onOverflowPinsToggle(!!msg.enabled);
interactionLogger.record('overflow_pins_toggled', { enabled: !!msg.enabled });
break;
case 'cs4g:overflow-node-double-clicked':
// The overflow graph node double-click opens the
// SLD overlay for that voltage level (or
// substation, depending on the backend). The
// parent's `onVlOpen` handler routes through the
// same path used by the NAD double-click.
if (onVlOpen) onVlOpen(msg.name);
interactionLogger.record('overflow_node_double_clicked', { name: msg.name });
break;
}
};
window.addEventListener('message', onMessage);
// When the overflow tab is detached, the iframe lives inside a
// popup window. Its `window.parent.postMessage(...)` calls hit
// the popup window, not the main one — so we listen there too.
// Without this binding the overlay-ready handshake (and every
// subsequent pin / filter / node event) would be dropped after
// a layout switch remounts the iframe inside the popup.
const popup = (detachedWindow && detachedWindow !== window && !detachedWindow.closed)
? detachedWindow : null;
if (popup) {
popup.addEventListener('message', onMessage);
}
return () => {
window.removeEventListener('message', onMessage);
if (popup) {
try { popup.removeEventListener('message', onMessage); } catch { /* popup torn down */ }
}
};
}, [onVlOpen, onOverflowPinPreview, onOverflowPinDoubleClick,
onOverviewFiltersChange, onOverflowPinsToggle,
onSimulateUnsimulatedAction, detachedWindow]);
// Outside-click + Escape dismissal for the overflow pin popover —
// mirrors `ActionOverviewDiagram`. Clicks INSIDE the iframe (the
// pin itself, the graph background) live in a different document
// and don't reach the parent's mousedown handler, so they don't
// close the popover. To prevent that the iframe's own background
// click is forwarded by the upstream interactive_html clicker
// through the existing `cs4g:overflow-node-double-clicked` path
// (clicks not on a pin are silently ignored), so the popover
// stays open until the user clicks outside the iframe / hits Esc
// / clicks the popover's own close button.
React.useEffect(() => {
if (!overflowPopoverPin) return;
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (overflowPopoverRef.current && target
&& overflowPopoverRef.current.contains(target)) return;
// Clicks ON the iframe itself bubble up to the parent as
// an event whose target is the iframe element. Don't close
// the popover in that case — the click might be a follow-up
// pin click handled separately.
if (target instanceof Element
&& target === overflowIframeRef.current) return;
setOverflowPopoverPin(null);
setOverflowPopoverViewport(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOverflowPopoverPin(null);
setOverflowPopoverViewport(null);
}
};
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [overflowPopoverPin]);
React.useEffect(() => {
const iframe = overflowIframeRef.current;
if (!iframe || !iframe.contentWindow) return;
if (!overlayReady) return;
const msg: ParentToIframeMessage = {
type: 'cs4g:pins',
visible: !!overflowPinsEnabled,
pins: overflowPinsEnabled ? (overflowPins ?? []) : [],
};
iframe.contentWindow.postMessage(msg, '*');
}, [overlayReady, overflowPinsEnabled, overflowPins]);
// Broadcast the current Action-Overview filter state into the
// iframe whenever it changes (and once at overlay-ready time).
// The iframe's sidebar mirrors these chips so a change made on
// the Action Overview NAD instantly reflects in the overflow
// graph filter panel — and vice versa.
React.useEffect(() => {
const iframe = overflowIframeRef.current;
if (!iframe || !iframe.contentWindow) return;
if (!overlayReady) return;
if (!overviewFilters) return;
const msg: ParentToIframeMessage = {
type: 'cs4g:filters',
filters: overviewFilters,
};
iframe.contentWindow.postMessage(msg, '*');
}, [overlayReady, overviewFilters]);
// Broadcast the overflow-graph build time so the iframe overlay
// renders it as a subtitle right below the sidebar ``<h1>``. The
// overlay JS owns the DOM mutation — the React side only forwards
// the value, mirroring the pattern used for pins / filters above.
React.useEffect(() => {
const iframe = overflowIframeRef.current;
if (!iframe || !iframe.contentWindow) return;
if (!overlayReady) return;
const msg: ParentToIframeMessage = {
type: 'cs4g:overflow-meta',
overflowGraphTime: typeof overflowGraphTime === 'number' ? overflowGraphTime : null,
};
iframe.contentWindow.postMessage(msg, '*');
}, [overlayReady, overflowGraphTime]);
// Mirror the host's light/dark theme into the embedded overflow
// viewer. We read `<html data-theme>` directly (rather than the
// React theme state, which lives in independent useTheme instances)
// and re-broadcast whenever it flips via a MutationObserver — so a
// theme toggle anywhere in the app reaches the iframe.
React.useEffect(() => {
const iframe = overflowIframeRef.current;
if (!iframe || !iframe.contentWindow) return;
if (!overlayReady) return;
const send = () => {
const win = overflowIframeRef.current?.contentWindow;
if (!win) return;
const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const msg: ParentToIframeMessage = { type: 'cs4g:theme', theme };
win.postMessage(msg, '*');
};
send();
const observer = new MutationObserver(send);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, [overlayReady]);
return {
overflowIframeRef,
overflowPopoverRef,
overflowPopoverPin,
setOverflowPopoverPin,
overflowPopoverViewport,
setOverflowPopoverViewport,
};
}