Files
geointel/frontend/src/components/shell/SecondaryDisplay.tsx
T

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
}