Files
tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-config.ts
T
schalli 445b1d3100 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>
2026-09-22 12:12:18 +02:00

157 lines
6.4 KiB
TypeScript

/**
* xframe-config — Typen, Grenzen und reiner Resolver des XFrame-Widgets
* (quick-260921-qd3). Ohne React-Import, damit der Test schlank bleibt
* (Muster picture-frame-config.ts).
*
* T-QD3-03: Die API prueft Widget-Konfigurationen nicht inhaltlich
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb liegt die https-Pruefung
* ALLEIN hier und im Formular: das Formular weist eine Nicht-https-Adresse
* mit Meldung ab und speichert sie nicht; `resolveXframeConfig` laesst beim
* Rendern jede Adresse auf `null` fallen, die der echte `URL`-Parser nicht
* als `https:` erkennt (`http:`, `javascript:`, `data:`, `ftp:`, Unparsbares)
* — ein von Hand manipulierter Config-Wert wird auf dem eigenen Dashboard
* nicht einmal als `<iframe>` gerendert.
*
* T-QD3-01, Sandbox: `allow-same-origin` bleibt bewusst drin — ohne dieses
* Token laeuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr
* `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten
* sind dann unbenutzbar. Der Origin ist der der FREMDSEITE, nicht Tesseras;
* die Sandbox hat hier allein die Aufgabe, die Navigation des obersten
* Fensters und Modaldialoge zu unterbinden. Bewusst FEHLEN deshalb
* `allow-top-navigation`, `allow-top-navigation-by-user-activation` (die
* eingebettete Seite kann den Tessera-Tab nicht umlenken) und `allow-modals`
* (keine `alert`/`confirm`/`prompt`, die als Tessera-Dialoge missdeutet
* werden koennten). `allow-popups-to-escape-sandbox` betrifft nur NEUE
* Fenster (z. B. Anmelde-Popups der Fremdseite), nie den Tessera-Tab.
*/
import { isHttpsUrl } from './picture-frame-config';
/** Auswahlstufen des Formulars in Sekunden; 0 = nie. */
export const XFRAME_RELOAD_OPTIONS: number[] = [0, 60, 300, 600, 1800, 3600];
/** Unter der kleinsten Stufe heisst „nie“ — schuetzt die Fremdseite vor Sekundentakt (T-QD3-08). */
export const XFRAME_RELOAD_MIN = 60;
export const XFRAME_RELOAD_MAX = 3600;
export const XFRAME_TITLE_MAX = 100;
/** Exakter Sandbox-Wert des <iframe> — Begruendung im Dateikopf. */
export const XFRAME_SANDBOX =
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
// Bewusst geteilt mit dem Bilderrahmen: EINE Regel fuer „https-Adresse“ im
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
export { isHttpsUrl };
/** Virtuelle Layoutbreite der eingebetteten Seite in Pixeln — Konstante, keine UI. */
export const XFRAME_PAGE_WIDTH = 1280;
export const XFRAME_CROP_MIN_W = 100;
export const XFRAME_CROP_MIN_H = 60;
/** Speichergrenze fuer getippte Werte; die Vorschau zeigt 3000 (xframe-crop.ts). */
export const XFRAME_CROP_MAX_H = 4000;
/** Ausschnitt in Seitenpixeln bei XFRAME_PAGE_WIDTH. */
export interface XframeCrop {
x: number;
y: number;
w: number;
h: number;
}
export const XFRAME_CROP_DEFAULT: XframeCrop = { x: 0, y: 0, w: XFRAME_PAGE_WIDTH, h: 720 };
/** Vergroesserung der ganzen Seite in Prozent (nur ohne Ausschnitt). */
export const XFRAME_ZOOM_OPTIONS: number[] = [50, 60, 75, 90, 100, 125, 150];
export const XFRAME_ZOOM_DEFAULT = 100;
export interface XframeConfig {
/** null = keine gueltige https-Adresse -> Leerzustand. */
url: string | null;
title: string;
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
reloadSeconds: number;
/** null = ganze Seite; sonst geklemmter Ausschnitt (quick-260922-ge2). */
crop: XframeCrop | null;
/** Stufe aus XFRAME_ZOOM_OPTIONS, wirkt nur bei crop === null. */
zoom: number;
/** Transparente Flaeche ueber dem Rahmen im Ansichtsmodus. */
readOnly: boolean;
}
/**
* EINE Klemmregel fuer Resolver, Zahlenfelder und Ziehen: runden, Breite
* [100, 1280], Hoehe [60, 4000], Position >= 0, dann x so verschieben, dass
* der Ausschnitt in der Seite bleibt (nie abweisen, T-GE2-05).
*/
export function clampXframeCrop(raw: XframeCrop): XframeCrop {
const w = Math.min(XFRAME_PAGE_WIDTH, Math.max(XFRAME_CROP_MIN_W, Math.round(raw.w)));
const h = Math.min(XFRAME_CROP_MAX_H, Math.max(XFRAME_CROP_MIN_H, Math.round(raw.h)));
let x = Math.max(0, Math.round(raw.x));
const y = Math.max(0, Math.round(raw.y));
if (x + w > XFRAME_PAGE_WIDTH) x = XFRAME_PAGE_WIDTH - w;
return { x, y, w, h };
}
function resolveUrl(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return isHttpsUrl(trimmed) ? trimmed : null;
}
function resolveTitle(raw: unknown): string {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, XFRAME_TITLE_MAX);
}
/**
* Groesste Auswahlstufe <= n, damit das <select> im Formular immer eine
* passende Option zeigt; unter der kleinsten Stufe -> 0 (nie).
*/
function resolveReload(raw: unknown): number {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 0;
if (raw < XFRAME_RELOAD_MIN) return 0;
if (raw >= XFRAME_RELOAD_MAX) return XFRAME_RELOAD_MAX;
let best = 0;
for (const option of XFRAME_RELOAD_OPTIONS) {
if (option <= raw && option > best) best = option;
}
return best;
}
function finiteNumber(raw: unknown): raw is number {
return typeof raw === 'number' && Number.isFinite(raw);
}
/** Typwaechter statt Cast: ein Nicht-null-Objekt darf als Schluessel-Wert-Menge gelesen werden. */
function isRecord(raw: unknown): raw is Record<string, unknown> {
return typeof raw === 'object' && raw !== null;
}
/** Nur ein Nicht-null-Objekt mit vier endlichen Zahlen x, y, w, h -> geklemmt; alles andere -> null. */
function resolveCrop(raw: unknown): XframeCrop | null {
if (!isRecord(raw)) return null;
const { x, y, w, h } = raw;
if (!finiteNumber(x) || !finiteNumber(y) || !finiteNumber(w) || !finiteNumber(h)) return null;
return clampXframeCrop({ x, y, w, h });
}
/** Groesste Zoom-Stufe <= n (Muster resolveReload); unter 50 -> 50; nicht endlich -> 100. */
function resolveZoom(raw: unknown): number {
if (!finiteNumber(raw)) return XFRAME_ZOOM_DEFAULT;
let best = XFRAME_ZOOM_OPTIONS[0];
for (const option of XFRAME_ZOOM_OPTIONS) {
if (option <= raw && option > best) best = option;
}
return best;
}
function resolveReadOnly(raw: unknown): boolean {
return raw === true;
}
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
return {
url: resolveUrl(config.url),
title: resolveTitle(config.title),
reloadSeconds: resolveReload(config.reloadSeconds),
crop: resolveCrop(config.crop),
zoom: resolveZoom(config.zoom),
readOnly: resolveReadOnly(config.readOnly),
};
}