Files
geointel/frontend/src/components/shell/SecondaryDisplay.tsx
T
JensandClaude Opus 5 663855f8a4 Geef het tweede scherm zijn opmaak en lettertypen
De analyseconsole opent in een eigen venster en stond daar in Times New Roman
op wit, met standaard browserknoppen, terwijl de werkbank ernaast donker was.
Niemand had er ooit naar gekeken.

Er was code die de opmaak moest meenemen, maar die kloonde de link naar het
nieuwe venster. Dat venster wordt geopend met window.open('') en staat dus op
about:blank; nagemeten leverde link.sheet === null op. De regels worden nu
ingeschreven in plaats van gelinkt, met de verwijzingen absoluut gemaakt tegen
de bron van het blad.

Daarna bleef document.fonts.status eeuwig op 'loading' staan. De bestanden
kwamen wel binnen met status 200, maar in een about:blank-document voltooit het
lettertypeladen niet. Een FontFace hangt niet aan een document, dus de
gezichten die het hoofdvenster al geladen heeft worden nu overgenomen en gaan
de @font-face-regels niet mee. Meetbaar: status 'loaded', en een schermafdruk
die op de lettertypen wacht is klaar in 45 ms in plaats van af te breken na
dertig seconden.

Dit verklaarde ook de vijftien !important in dit blok: die probeerden iets te
overschrijven dat er nooit aankwam. Alle zestien bleken overbodig en zijn in de
vorige commit verwijderd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:00:30 +02:00

254 lines
9.0 KiB
TypeScript

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 <link rel="stylesheet"> 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<HTMLLinkElement | HTMLStyleElement>('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 = `
<div class="secondary-display-brand">
<img src="/geointel-icon.svg" alt="" aria-hidden="true" />
<span><small>LIVE GEKOPPELD</small><strong>Analyseconsole</strong></span>
</div>
<div class="secondary-display-status"><i aria-hidden="true"></i><span>Gesynchroniseerd met de kaart</span></div>
`
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<Window | null>(null)
const monitorTimerRef = useRef<number | null>(null)
const [container, setContainer] = useState<HTMLElement | null>(null)
const [error, setError] = useState<string | null>(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
}