319 lines
11 KiB
TypeScript
319 lines
11 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 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 = `
|
|
<div class="secondary-display-brand">
|
|
<img src="/geointel-icon.svg" alt="" aria-hidden="true" />
|
|
<span><small>LIVE GEKOPPELD</small><h1 tabindex="-1">Analyseconsole</h1></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')
|
|
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<HTMLElement>('h1')?.focus({ preventScroll: true })
|
|
return content
|
|
}
|
|
|
|
export function useSecondaryDisplay() {
|
|
const popupRef = useRef<Window | null>(null)
|
|
const monitorTimerRef = useRef<number | null>(null)
|
|
const returnFocusRef = useRef<HTMLElement | 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 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
|
|
}
|