import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { parseSecondaryDisplayGeometry, secondaryDisplayFeatures, type SecondaryDisplayGeometry, } from './secondaryDisplayGeometry' const SECONDARY_DISPLAY_NAME = 'geointel-analysis-console' const SECONDARY_DISPLAY_GEOMETRY_KEY = 'geointel.secondary-display.geometry.v1' function defaultGeometry(): SecondaryDisplayGeometry { return { left: window.screenX + Math.max(80, window.outerWidth - 32), top: window.screenY, width: 620, height: Math.max(640, Math.min(1_080, window.outerHeight)), } } /** * Neemt de opmaak van het hoofdvenster mee naar de console. * * De vorige versie kloonde de naar het nieuwe venster. * Dat venster wordt geopend met window.open('') en is dus about:blank; daar * werd de link wel in de head gezet maar nooit opgehaald — nagemeten leverde * link.sheet === null op. Gevolg: de console stond volledig onopgemaakt, in * Times New Roman op wit, terwijl de werkbank ernaast donker was. Het verklaart * ook waarom de paneelregels voor dit venster met !important stonden: die * probeerden iets te overschrijven dat er nooit aankwam. Sinds de opmaak hier * wel aankomt zijn ze overbodig gebleken en verwijderd. * * Nu worden de regels zelf ingeschreven. Dat is dezelfde oorsprong, dus * cssRules is leesbaar, en er komt geen netwerkverzoek aan te pas. Lukt het * lezen toch niet, dan valt hij terug op de gekloonde link. */ function copyDocumentStyles(target: Document): void { const regels: string[] = [] let alleenGelezen = true // Verwijzingen in de regels — lettertypen, iconen, achtergronden — staan // relatief. Het nieuwe venster is about:blank en heeft dus geen basis om ze // tegen op te lossen; zonder deze stap blijft het wachten op lettertypen die // nooit aankomen. Ze worden hier absoluut gemaakt tegen de bron van het blad. const maakAbsoluut = (tekst: string, basis: string): string => tekst.replace(/url\((['"]?)([^'")]+)\1\)/g, (heel, quote, verwijzing) => { if (/^(data:|blob:|https?:|\/\/)/i.test(verwijzing)) return heel try { return `url("${new URL(verwijzing, basis).href}")` } catch { return heel } }) // @font-face gaat bewust niet mee. De bestanden komen in dit venster wel // binnen met status 200, maar de FontFace springt nooit naar 'loaded': in een // document dat op about:blank staat voltooit het lettertypeladen niet. Het // gevolg was dat document.fonts.status eeuwig op 'loading' bleef en de console // kort in terugvalletters opende. De gezichten worden in plaats daarvan // overgenomen uit het hoofdvenster, waar ze al geladen zijn — zie // copyLoadedFonts hieronder. const isFontFace = (regel: CSSRule): boolean => typeof CSSFontFaceRule !== 'undefined' && regel instanceof CSSFontFaceRule for (const sheet of Array.from(document.styleSheets)) { try { const basis = sheet.href ?? document.baseURI const tekst = Array.from(sheet.cssRules) .filter((regel) => !isFontFace(regel)) .map((regel) => maakAbsoluut(regel.cssText, basis)) .join('\n') if (tekst) regels.push(tekst) } catch { alleenGelezen = false } } if (regels.length > 0) { const stijl = target.createElement('style') stijl.setAttribute('data-herkomst', 'hoofdvenster') stijl.textContent = regels.join('\n') target.head.append(stijl) } if (alleenGelezen && regels.length > 0) return // Terugval voor bladen die niet te lezen zijn, bijvoorbeeld van een ander domein. document.head.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => { const clone = node.cloneNode(true) as HTMLLinkElement | HTMLStyleElement if (clone instanceof HTMLLinkElement && node instanceof HTMLLinkElement) clone.href = node.href target.head.append(clone) }) } /** * Neemt de al geladen lettertypegezichten van het hoofdvenster over. * * Een FontFace hangt niet aan een document, dus een gezicht dat hier al * ingeladen is kan rechtstreeks aan de FontFaceSet van het nieuwe venster * worden toegevoegd. Dat scheelt niet alleen een tweede download, het omzeilt * ook dat het laden in een about:blank-document nooit voltooit. */ function copyLoadedFonts(target: Window): void { const doel = target.document.fonts if (!doel || typeof document.fonts === 'undefined') return document.fonts.forEach((gezicht) => { try { doel.add(gezicht) } catch { // Al aanwezig, of dit gezicht laat zich niet overdragen. } }) } function syncSecondaryTheme(target: Window): void { const theme = document.body.dataset.theme if (theme === 'light' || theme === 'dark') target.document.body.dataset.theme = theme else delete target.document.body.dataset.theme } export function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement { const targetDocument = target.document targetDocument.documentElement.lang = document.documentElement.lang || 'nl' targetDocument.head.replaceChildren() const charset = targetDocument.createElement('meta') charset.setAttribute('charset', 'utf-8') const title = targetDocument.createElement('title') title.textContent = 'GeoIntel · Analyseconsole' const base = targetDocument.createElement('base') base.href = document.baseURI const viewport = targetDocument.createElement('meta') viewport.name = 'viewport' viewport.content = 'width=device-width, initial-scale=1' targetDocument.head.append(charset, title, base, viewport) copyDocumentStyles(targetDocument) copyLoadedFonts(target) const shell = targetDocument.createElement('div') shell.className = 'secondary-display-shell' const header = targetDocument.createElement('header') header.className = 'secondary-display-header' header.innerHTML = `
LIVE GEKOPPELD

Analyseconsole

Gesynchroniseerd met de kaart
` const closeButton = targetDocument.createElement('button') closeButton.type = 'button' closeButton.className = 'secondary-display-close' closeButton.textContent = 'Sluit console' closeButton.addEventListener('click', onClose) header.append(closeButton) const content = targetDocument.createElement('main') content.className = 'secondary-display-content' content.id = 'secondary-display-content' content.setAttribute('aria-label', 'Live analyse en resultaten') const contentHeading = targetDocument.createElement('h2') contentHeading.className = 'sr-only' contentHeading.textContent = 'Live analyse en resultaten' content.append(contentHeading) shell.append(header, content) targetDocument.body.replaceChildren(shell) targetDocument.body.className = 'secondary-display-body' syncSecondaryTheme(target) header.querySelector('h1')?.focus({ preventScroll: true }) return content } export function useSecondaryDisplay() { const popupRef = useRef(null) const monitorTimerRef = useRef(null) const returnFocusRef = useRef(null) const [container, setContainer] = useState(null) const [error, setError] = useState(null) const stopMonitoring = useCallback(() => { if (monitorTimerRef.current !== null) { window.clearInterval(monitorTimerRef.current) monitorTimerRef.current = null } }, []) const rememberGeometry = useCallback((popup: Window) => { const geometry: SecondaryDisplayGeometry = { left: popup.screenX, top: popup.screenY, width: popup.outerWidth, height: popup.outerHeight, } try { window.localStorage.setItem(SECONDARY_DISPLAY_GEOMETRY_KEY, JSON.stringify(geometry)) } catch { // Window positioning is a convenience; storage restrictions must never // break the live analysis console. } }, []) const restoreFocus = useCallback(() => { const target = returnFocusRef.current returnFocusRef.current = null if (target?.isConnected) target.focus({ preventScroll: true }) }, []) const close = useCallback(() => { const popup = popupRef.current if (popup && !popup.closed) { try { rememberGeometry(popup) } catch { // A navigated popup can refuse geometry access; closing still wins. } try { popup.close() } catch { // State is cleared below even when the browser has already detached it. } } popupRef.current = null setContainer(null) stopMonitoring() restoreFocus() }, [rememberGeometry, restoreFocus, stopMonitoring]) const open = useCallback(() => { const existing = popupRef.current if (existing && !existing.closed) { try { const existingContent = existing.document.getElementById('secondary-display-content') const content = existingContent?.nodeType === Node.ELEMENT_NODE ? existingContent as HTMLElement : initialiseSecondaryDocument(existing, close) syncSecondaryTheme(existing) setContainer(content) setError(null) existing.focus() } catch { try { existing.close() } finally { popupRef.current = null setContainer(null) stopMonitoring() } setError('Het tweede venster kon niet opnieuw worden gekoppeld. Sluit het venster en probeer opnieuw.') } return } let saved: SecondaryDisplayGeometry | null = null try { saved = parseSecondaryDisplayGeometry(window.localStorage.getItem(SECONDARY_DISPLAY_GEOMETRY_KEY)) } catch { saved = null } returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null const popup = window.open('', SECONDARY_DISPLAY_NAME, secondaryDisplayFeatures(saved ?? defaultGeometry())) if (!popup) { setError('De browser blokkeerde het tweede venster. Sta pop-ups voor GeoIntel toe en probeer opnieuw.') return } setError(null) popupRef.current = popup const content = initialiseSecondaryDocument(popup, close) setContainer(content) popup.focus() stopMonitoring() monitorTimerRef.current = window.setInterval(() => { const current = popupRef.current if (!current || current.closed) { popupRef.current = null setContainer(null) stopMonitoring() restoreFocus() return } rememberGeometry(current) }, 1_000) }, [close, rememberGeometry, restoreFocus, stopMonitoring]) useEffect(() => { if (!container) return const sync = () => { const popup = popupRef.current if (popup && !popup.closed) syncSecondaryTheme(popup) } sync() const observer = new MutationObserver(sync) observer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] }) return () => observer.disconnect() }, [container]) useEffect(() => close, [close]) return { container, error, isOpen: container !== null, open, close, } } interface SecondaryDisplayTargetProps { container: HTMLElement | null children: ReactNode } export function SecondaryDisplayTarget({ container, children }: SecondaryDisplayTargetProps) { return container ? createPortal(children, container) : children }