feat(quick-260922-ge2): XFrame-Widget - Ausschnitt der Seite waehlen und einpassen, Zoom fuer die ganze Seite, Nur anzeigen
- Resolver: crop (x/y/w/h, geklemmt auf 1280er-Seite, x verschoben statt abgewiesen), zoom (50..150, groesste Stufe <= n), readOnly (nur echtes true) - xframe-crop.ts: reine Geometrie - computeCropLayout (contain + zentriert, scale 0 bis gemessen), applyCropDrag (Verschieben, vier Ecken, Gegenecke bleibt) - Kachel: Clip + verschobener, skalierter <iframe> bei fester Layoutbreite 1280, ResizeObserver am Koerper; Zoom-Zweig mit Prozentmassen; 100 % wie bisher; readOnly-Flaeche nur im Ansichtsmodus - Formular: Checkbox Ausschnitt (ein Aufruf mit crop + readOnly), Vorschau 1280 px breit mit derselben Sandbox und pointer-events none, Rahmen mit vier Griffen (Pointer-Events, Capture-Waechter fuer jsdom), Zahlenfelder, Zoom-Auswahl nur ohne Ausschnitt, Nur anzeigen, dauerhafter Hinweis - Rahmen als <fieldset> statt div role=group (Biome useSemanticElements, kein biome-ignore) - Test-Helfer stubResizeObserver, 13 neue Schluessel de/en, Ausschnitt auf der Umlaut-Allowlist; Web-Tests 604 -> 640 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
import { XFRAME_PAGE_WIDTH, XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
import { computeCropLayout } from './xframe-crop';
|
||||
|
||||
/**
|
||||
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
|
||||
@@ -38,11 +39,36 @@ import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
|
||||
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
||||
* Rasters und startet im Bearbeitungsmodus kein Ziehen).
|
||||
*
|
||||
* Ausschnitt (quick-260922-ge2): cross-origin kann Tessera die Seite nicht
|
||||
* von aussen scrollen oder zuschneiden. Deshalb wird der <iframe> SELBST
|
||||
* mit `left/top` verschoben und mit `transform: scale` skaliert, und ein
|
||||
* Clip-`div` (overflow hidden) schneidet ihn auf den Ausschnitt. Der Rahmen
|
||||
* behaelt die feste Layoutbreite `XFRAME_PAGE_WIDTH` (1280): sie haelt den
|
||||
* Seitenaufbau ueber alle Kachelgroessen stabil — sonst braeche die Seite
|
||||
* bei jeder Kachelgroesse anders um und der in den Einstellungen gewaehlte
|
||||
* Ausschnitt verrutschte. Die Kachelgroesse liefert ein ResizeObserver am
|
||||
* Koerper (nur im Ausschnitt-Modus angehaengt); `computeCropLayout` passt
|
||||
* den Ausschnitt ein (contain) und zentriert ihn. Ohne Ausschnitt gilt die
|
||||
* Vergroesserung `zoom` (Prozentmasse + scale); 100 % rendert wie bisher.
|
||||
*
|
||||
* „Nur anzeigen“ (`readOnly`, T-GE2-02): eine transparente Flaeche NACH dem
|
||||
* Rahmen sperrt Zeigerereignisse — Klicken und Scrollen in der Seite. Das
|
||||
* ist Bedienkomfort, KEINE Sicherheitsmassnahme: die Seite laedt und laeuft
|
||||
* weiter (Skripte, Cookies, Neuladen), die Sicherheitsgrenze bleibt die
|
||||
* Sandbox. Im Bearbeitungsmodus liegt nur die Bearbeitungsflaeche, nie
|
||||
* beide.
|
||||
*/
|
||||
export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config]);
|
||||
const { url, title, reloadSeconds, crop, zoom, readOnly } = useMemo(
|
||||
() => resolveXframeConfig(config),
|
||||
[config],
|
||||
);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
const [tile, setTile] = useState({ w: 0, h: 0 });
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const hasCrop = crop !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
||||
@@ -50,6 +76,18 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
return () => clearInterval(timer);
|
||||
}, [url, reloadSeconds, isEditMode]);
|
||||
|
||||
// Kachelgroesse nur im Ausschnitt-Modus messen (Muster dashboard-grid.tsx).
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (!el || !hasCrop) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const rect = entries[0].contentRect;
|
||||
setTile({ w: rect.width, h: rect.height });
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasCrop]);
|
||||
|
||||
if (url === null) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
@@ -59,6 +97,21 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
}
|
||||
|
||||
const openLabel = t('xframe.openInNewTab');
|
||||
const frameKey = `${url}#${reloadNonce}`;
|
||||
const frameAttrs = {
|
||||
src: url,
|
||||
title: title !== '' ? title : url,
|
||||
sandbox: XFRAME_SANDBOX,
|
||||
allow: '',
|
||||
referrerPolicy: 'no-referrer' as const,
|
||||
loading: 'lazy' as const,
|
||||
'data-testid': 'xframe-frame',
|
||||
'data-reload-nonce': reloadNonce,
|
||||
};
|
||||
const layout = crop === null ? null : computeCropLayout(crop, tile.w, tile.h);
|
||||
const zoomFactor = zoom / 100;
|
||||
// 60 % -> 166.67 %, 150 % -> 66.67 %: der Rahmen wird groesser gelegt und dann verkleinert.
|
||||
const zoomPct = `${Math.round(10000 / zoomFactor) / 100}%`;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
@@ -75,22 +128,61 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<iframe
|
||||
key={`${url}#${reloadNonce}`}
|
||||
src={url}
|
||||
title={title !== '' ? title : url}
|
||||
sandbox={XFRAME_SANDBOX}
|
||||
allow=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
data-testid="xframe-frame"
|
||||
data-reload-nonce={reloadNonce}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="relative min-h-0 flex-1 overflow-hidden"
|
||||
data-tile-size={hasCrop ? `${Math.round(tile.w)}x${Math.round(tile.h)}` : undefined}
|
||||
>
|
||||
{crop !== null && layout !== null ? (
|
||||
layout.scale > 0 && (
|
||||
<div
|
||||
data-testid="xframe-crop-clip"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: layout.left,
|
||||
top: layout.top,
|
||||
width: crop.w * layout.scale,
|
||||
height: crop.h * layout.scale,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
key={frameKey}
|
||||
{...frameAttrs}
|
||||
className="border-0 bg-background"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -crop.x * layout.scale,
|
||||
top: -crop.y * layout.scale,
|
||||
width: XFRAME_PAGE_WIDTH,
|
||||
height: layout.frameHeight,
|
||||
transform: `scale(${layout.scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : zoomFactor !== 1 ? (
|
||||
<iframe
|
||||
key={frameKey}
|
||||
{...frameAttrs}
|
||||
className="absolute left-0 top-0 border-0 bg-background"
|
||||
style={{
|
||||
width: zoomPct,
|
||||
height: zoomPct,
|
||||
transform: `scale(${zoomFactor})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<iframe key={frameKey} {...frameAttrs} className="h-full w-full border-0 bg-background" />
|
||||
)}
|
||||
{isEditMode && (
|
||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
|
||||
)}
|
||||
{!isEditMode && readOnly && (
|
||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-readonly-overlay" />
|
||||
)}
|
||||
{title === '' && (
|
||||
<NewTabLink
|
||||
url={url}
|
||||
|
||||
Reference in New Issue
Block a user