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 initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement { const targetDocument = target.document targetDocument.title = 'GeoIntel · Analyseconsole' targetDocument.documentElement.lang = document.documentElement.lang || 'nl' targetDocument.head.replaceChildren() const viewport = targetDocument.createElement('meta') viewport.name = 'viewport' viewport.content = 'width=device-width, initial-scale=1' targetDocument.head.append(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 GEKOPPELDAnalyseconsole
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') shell.append(header, content) targetDocument.body.replaceChildren(shell) targetDocument.body.className = 'secondary-display-body' return content } export function useSecondaryDisplay() { const popupRef = useRef(null) const monitorTimerRef = 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 close = useCallback(() => { const popup = popupRef.current if (popup && !popup.closed) { rememberGeometry(popup) popup.close() } popupRef.current = null setContainer(null) stopMonitoring() }, [rememberGeometry, stopMonitoring]) const open = useCallback(() => { const existing = popupRef.current if (existing && !existing.closed) { existing.focus() return } let saved: SecondaryDisplayGeometry | null = null try { saved = parseSecondaryDisplayGeometry(window.localStorage.getItem(SECONDARY_DISPLAY_GEOMETRY_KEY)) } catch { saved = 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() return } rememberGeometry(current) }, 1_000) }, [close, rememberGeometry, stopMonitoring]) 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 }