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,10 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
XFRAME_CROP_DEFAULT,
|
||||||
|
XFRAME_PAGE_WIDTH,
|
||||||
XFRAME_RELOAD_MAX,
|
XFRAME_RELOAD_MAX,
|
||||||
XFRAME_RELOAD_MIN,
|
XFRAME_RELOAD_MIN,
|
||||||
XFRAME_RELOAD_OPTIONS,
|
XFRAME_RELOAD_OPTIONS,
|
||||||
XFRAME_SANDBOX,
|
XFRAME_SANDBOX,
|
||||||
XFRAME_TITLE_MAX,
|
XFRAME_TITLE_MAX,
|
||||||
|
XFRAME_ZOOM_OPTIONS,
|
||||||
|
clampXframeCrop,
|
||||||
isHttpsUrl,
|
isHttpsUrl,
|
||||||
resolveXframeConfig,
|
resolveXframeConfig,
|
||||||
} from './xframe-config';
|
} from './xframe-config';
|
||||||
@@ -19,7 +23,14 @@ import {
|
|||||||
*/
|
*/
|
||||||
describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
||||||
it('Test 1: leere Konfiguration -> keine Adresse, leerer Titel, nie neu laden', () => {
|
it('Test 1: leere Konfiguration -> keine Adresse, leerer Titel, nie neu laden', () => {
|
||||||
expect(resolveXframeConfig({})).toEqual({ url: null, title: '', reloadSeconds: 0 });
|
expect(resolveXframeConfig({})).toEqual({
|
||||||
|
url: null,
|
||||||
|
title: '',
|
||||||
|
reloadSeconds: 0,
|
||||||
|
crop: null,
|
||||||
|
zoom: 100,
|
||||||
|
readOnly: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 2: https-Adresse bleibt (getrimmt)', () => {
|
it('Test 2: https-Adresse bleibt (getrimmt)', () => {
|
||||||
@@ -64,7 +75,101 @@ describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
|||||||
it('Test 8: alle drei Felder zusammen', () => {
|
it('Test 8: alle drei Felder zusammen', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveXframeConfig({ url: 'https://a.de/', title: ' Board ', reloadSeconds: 1800 }),
|
resolveXframeConfig({ url: 'https://a.de/', title: ' Board ', reloadSeconds: 1800 }),
|
||||||
).toEqual({ url: 'https://a.de/', title: 'Board', reloadSeconds: 1800 });
|
).toEqual({
|
||||||
|
url: 'https://a.de/',
|
||||||
|
title: 'Board',
|
||||||
|
reloadSeconds: 1800,
|
||||||
|
crop: null,
|
||||||
|
zoom: 100,
|
||||||
|
readOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-260922-ge2: Ausschnitt, Zoom, readOnly. Die Werte landen in
|
||||||
|
* Inline-Styles (T-GE2-05) — der Resolver klemmt und weist nie ab: ein
|
||||||
|
* manipulierter Wert ergibt hoechstens einen anderen Ausschnitt, nie
|
||||||
|
* `scale(NaN)` oder einen 10⁹-px-Rahmen.
|
||||||
|
*/
|
||||||
|
describe('resolveXframeConfig — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
|
||||||
|
it('Test 13: crop fehlt / null / String / unvollstaendig / Nicht-Zahl -> null', () => {
|
||||||
|
expect(resolveXframeConfig({}).crop).toBeNull();
|
||||||
|
expect(resolveXframeConfig({ crop: null }).crop).toBeNull();
|
||||||
|
expect(resolveXframeConfig({ crop: 'x' }).crop).toBeNull();
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 1 } }).crop).toBeNull();
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 'a', y: 0, w: 500, h: 300 } }).crop).toBeNull();
|
||||||
|
expect(resolveXframeConfig({ crop: { x: Number.NaN, y: 0, w: 500, h: 300 } }).crop).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 14: gueltiger Ausschnitt bleibt unveraendert; x wird verschoben, wenn x + w > 1280 (nie abgewiesen)', () => {
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 1280, h: 720 } }).crop).toEqual({ x: 0, y: 0, w: 1280, h: 720 });
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 500, y: 0, w: 1000, h: 300 } }).crop).toEqual({ x: 280, y: 0, w: 1000, h: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 15: Breite und Hoehe geklemmt — w 50 -> 100, w 5000 -> 1280 (x -> 0), h 10 -> 60, h 9999 -> 4000', () => {
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 50, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 100, h: 300 });
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 300, y: 0, w: 5000, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 1280, h: 300 });
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 500, h: 10 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 60 });
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 500, h: 9999 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 4000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 16: negative Position -> 0; Nachkommastellen gerundet', () => {
|
||||||
|
expect(resolveXframeConfig({ crop: { x: -5, y: -1, w: 500, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 300 });
|
||||||
|
expect(resolveXframeConfig({ crop: { x: 10.6, y: 2.4, w: 500.4, h: 300.5 } }).crop).toEqual({
|
||||||
|
x: 11,
|
||||||
|
y: 2,
|
||||||
|
w: 500,
|
||||||
|
h: 301,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 17: zoom fehlt / "abc" / NaN -> 100; 70 -> 60, 200 -> 150, 0 -> 50, 10 -> 50, 125 -> 125', () => {
|
||||||
|
expect(resolveXframeConfig({}).zoom).toBe(100);
|
||||||
|
expect(resolveXframeConfig({ zoom: 'abc' }).zoom).toBe(100);
|
||||||
|
expect(resolveXframeConfig({ zoom: Number.NaN }).zoom).toBe(100);
|
||||||
|
expect(resolveXframeConfig({ zoom: 70 }).zoom).toBe(60);
|
||||||
|
expect(resolveXframeConfig({ zoom: 200 }).zoom).toBe(150);
|
||||||
|
expect(resolveXframeConfig({ zoom: 0 }).zoom).toBe(50);
|
||||||
|
expect(resolveXframeConfig({ zoom: 10 }).zoom).toBe(50);
|
||||||
|
expect(resolveXframeConfig({ zoom: 125 }).zoom).toBe(125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 18: readOnly nur bei echtem true; "true", 1, fehlt -> false', () => {
|
||||||
|
expect(resolveXframeConfig({ readOnly: true }).readOnly).toBe(true);
|
||||||
|
expect(resolveXframeConfig({ readOnly: 'true' }).readOnly).toBe(false);
|
||||||
|
expect(resolveXframeConfig({ readOnly: 1 }).readOnly).toBe(false);
|
||||||
|
expect(resolveXframeConfig({}).readOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 19: Konstanten — Zoom-Stufen, Seitenbreite 1280, Vorgabe-Ausschnitt 0/0/1280/720', () => {
|
||||||
|
expect(XFRAME_ZOOM_OPTIONS).toEqual([50, 60, 75, 90, 100, 125, 150]);
|
||||||
|
expect(XFRAME_PAGE_WIDTH).toBe(1280);
|
||||||
|
expect(XFRAME_CROP_DEFAULT).toEqual({ x: 0, y: 0, w: 1280, h: 720 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 20: alle sechs Felder zusammen', () => {
|
||||||
|
expect(
|
||||||
|
resolveXframeConfig({
|
||||||
|
url: 'https://a.de/',
|
||||||
|
title: 'Board',
|
||||||
|
reloadSeconds: 60,
|
||||||
|
crop: { x: 100, y: 200, w: 800, h: 400 },
|
||||||
|
zoom: 75,
|
||||||
|
readOnly: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
url: 'https://a.de/',
|
||||||
|
title: 'Board',
|
||||||
|
reloadSeconds: 60,
|
||||||
|
crop: { x: 100, y: 200, w: 800, h: 400 },
|
||||||
|
zoom: 75,
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 21: clampXframeCrop ist exportiert — { 1200, 0, 200, 100 } -> x 1080', () => {
|
||||||
|
expect(clampXframeCrop({ x: 1200, y: 0, w: 200, h: 100 })).toEqual({ x: 1080, y: 0, w: 200, h: 100 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,50 @@ export const XFRAME_SANDBOX =
|
|||||||
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
|
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
|
||||||
export { isHttpsUrl };
|
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 {
|
export interface XframeConfig {
|
||||||
/** null = keine gueltige https-Adresse -> Leerzustand. */
|
/** null = keine gueltige https-Adresse -> Leerzustand. */
|
||||||
url: string | null;
|
url: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
|
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
|
||||||
reloadSeconds: number;
|
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 {
|
function resolveUrl(raw: unknown): string | null {
|
||||||
@@ -75,10 +113,44 @@ function resolveReload(raw: unknown): number {
|
|||||||
return best;
|
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 {
|
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
|
||||||
return {
|
return {
|
||||||
url: resolveUrl(config.url),
|
url: resolveUrl(config.url),
|
||||||
title: resolveTitle(config.title),
|
title: resolveTitle(config.title),
|
||||||
reloadSeconds: resolveReload(config.reloadSeconds),
|
reloadSeconds: resolveReload(config.reloadSeconds),
|
||||||
|
crop: resolveCrop(config.crop),
|
||||||
|
zoom: resolveZoom(config.zoom),
|
||||||
|
readOnly: resolveReadOnly(config.readOnly),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { XFRAME_PREVIEW_PAGE_HEIGHT, applyCropDrag, computeCropLayout } from './xframe-crop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xframe-crop.test — NEU (quick-260922-ge2).
|
||||||
|
*
|
||||||
|
* Reine Geometrie ohne React: Einpassen (contain) und Zentrieren des
|
||||||
|
* Ausschnitts in der Kachel, Layouthoehe des Rahmens, Nullkachel ohne
|
||||||
|
* Division durch 0 (T-GE2-05); Ziehmathematik der Vorschau — Verschieben
|
||||||
|
* bei fester Groesse, Ecken bewegen nur ihre zwei Kanten, die Gegenecke
|
||||||
|
* bleibt stehen, Mindestgroesse an der bewegten Kante.
|
||||||
|
*/
|
||||||
|
const FULL = { x: 0, y: 0, w: 1280, h: 720 };
|
||||||
|
const START = { x: 100, y: 100, w: 400, h: 300 };
|
||||||
|
|
||||||
|
describe('computeCropLayout (quick-260922-ge2)', () => {
|
||||||
|
it('Test 1: breitenbegrenzt — 1280x720 in 640x720 -> scale 0.5, left 0, top 180', () => {
|
||||||
|
expect(computeCropLayout(FULL, 640, 720)).toEqual({ scale: 0.5, left: 0, top: 180, frameHeight: 720 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: hoehenbegrenzt — 1280x720 in 1280x180 -> scale 0.25, left 480, top 0', () => {
|
||||||
|
expect(computeCropLayout(FULL, 1280, 180)).toEqual({ scale: 0.25, left: 480, top: 0, frameHeight: 720 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: Vergroesserung erlaubt — 200x100 in 800x400 -> scale 4', () => {
|
||||||
|
expect(computeCropLayout({ x: 0, y: 0, w: 200, h: 100 }, 800, 400)).toEqual({
|
||||||
|
scale: 4,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
frameHeight: 720,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: frameHeight mindestens 720, sonst y + h', () => {
|
||||||
|
expect(computeCropLayout({ x: 0, y: 0, w: 1280, h: 100 }, 640, 360).frameHeight).toBe(720);
|
||||||
|
expect(computeCropLayout({ x: 0, y: 3000, w: 1280, h: 400 }, 640, 360).frameHeight).toBe(3400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5: ungemessene Kachel (0x0 oder 640x0) -> scale 0, left 0, top 0 (nichts rendern)', () => {
|
||||||
|
expect(computeCropLayout(FULL, 0, 0)).toEqual({ scale: 0, left: 0, top: 0, frameHeight: 720 });
|
||||||
|
expect(computeCropLayout(FULL, 640, 0)).toEqual({ scale: 0, left: 0, top: 0, frameHeight: 720 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyCropDrag (quick-260922-ge2)', () => {
|
||||||
|
it('Test 6: move um +100/+50 verschiebt bei fester Groesse', () => {
|
||||||
|
expect(applyCropDrag('move', START, 100, 50)).toEqual({ x: 200, y: 150, w: 400, h: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 7: move ueber den Rand -> an der Kante geklemmt (x 880 = 1280 - 400; -500/-500 -> 0/0)', () => {
|
||||||
|
expect(applyCropDrag('move', START, 2000, 0)).toEqual({ x: 880, y: 100, w: 400, h: 300 });
|
||||||
|
expect(applyCropDrag('move', START, -500, -500)).toEqual({ x: 0, y: 0, w: 400, h: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 8: se um +100/+100 -> groesser, obere linke Ecke bleibt', () => {
|
||||||
|
expect(applyCropDrag('se', START, 100, 100)).toEqual({ x: 100, y: 100, w: 500, h: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 9: nw um +350/+10 -> Mindestbreite 100 an der bewegten Kante, rechte Kante 500 bleibt', () => {
|
||||||
|
expect(applyCropDrag('nw', START, 350, 10)).toEqual({ x: 400, y: 110, w: 100, h: 290 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 10: ne um +2000/0 -> rechte Kante 1280 (w 1180), linke Kante bleibt', () => {
|
||||||
|
expect(applyCropDrag('ne', START, 2000, 0)).toEqual({ x: 100, y: 100, w: 1180, h: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 11: sw um 0/+5000 -> untere Kante an der Vorschauhoehe 3000 (h 2900)', () => {
|
||||||
|
expect(XFRAME_PREVIEW_PAGE_HEIGHT).toBe(3000);
|
||||||
|
expect(applyCropDrag('sw', START, 0, 5000)).toEqual({ x: 100, y: 100, w: 400, h: 2900 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* xframe-crop — reine Geometrie des XFrame-Ausschnitts (quick-260922-ge2),
|
||||||
|
* ohne React, fuer Kachel UND Vorschau.
|
||||||
|
*
|
||||||
|
* Einpassen (contain) und Zentrieren: der Ausschnitt muss in der Kachel
|
||||||
|
* immer GANZ sichtbar sein, und das Seitenverhaeltnis der Kachel stimmt
|
||||||
|
* praktisch nie mit dem des Ausschnitts ueberein — deshalb der kleinere
|
||||||
|
* der beiden Massstaebe (darf > 1 sein: ein kleiner Ausschnitt in einer
|
||||||
|
* grossen Kachel wird vergroessert) und die Zentrierung im Rest.
|
||||||
|
*
|
||||||
|
* `frameHeight` ist mindestens 720: die eingebettete Seite braucht eine
|
||||||
|
* Layouthoehe, sonst rendern viele Seiten ihren Inhalt nicht oder brechen
|
||||||
|
* ihn anders um; liegt der Ausschnitt tiefer, reicht der Rahmen bis zu
|
||||||
|
* seiner Unterkante.
|
||||||
|
*
|
||||||
|
* Ziehmathematik der Vorschau: `move` verschiebt bei fester Groesse und
|
||||||
|
* klemmt an den Seitenraendern; die Eckmodi bewegen nur die zwei Kanten
|
||||||
|
* ihrer Ecke — die gegenueberliegende Ecke bleibt stehen, damit sich der
|
||||||
|
* Rahmen so verhaelt, wie man es von jedem Bildeditor kennt; die
|
||||||
|
* Mindestgroesse wird an der BEWEGTEN Kante gehalten. Das Ergebnis geht
|
||||||
|
* durch `clampXframeCrop` (eine Klemmregel fuer alles, T-GE2-05).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
XFRAME_CROP_MIN_H,
|
||||||
|
XFRAME_CROP_MIN_W,
|
||||||
|
XFRAME_PAGE_WIDTH,
|
||||||
|
type XframeCrop,
|
||||||
|
clampXframeCrop,
|
||||||
|
} from './xframe-config';
|
||||||
|
|
||||||
|
/** Hoehe der Vorschau-Seite in Seitenpixeln (Vorschau und Ziehen); 4000 bleibt die Speichergrenze. */
|
||||||
|
export const XFRAME_PREVIEW_PAGE_HEIGHT = 3000;
|
||||||
|
/** Mindest-Layouthoehe des Rahmens im Ausschnitt-Modus. */
|
||||||
|
const MIN_FRAME_HEIGHT = 720;
|
||||||
|
|
||||||
|
export type XframeDragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se';
|
||||||
|
|
||||||
|
export interface XframeCropLayout {
|
||||||
|
/** 0 = Kachel noch nicht gemessen -> nichts rendern. */
|
||||||
|
scale: number;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
frameHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCropLayout(crop: XframeCrop, tileW: number, tileH: number): XframeCropLayout {
|
||||||
|
const frameHeight = Math.max(crop.y + crop.h, MIN_FRAME_HEIGHT);
|
||||||
|
if (tileW <= 0 || tileH <= 0) return { scale: 0, left: 0, top: 0, frameHeight };
|
||||||
|
const scale = Math.min(tileW / crop.w, tileH / crop.h);
|
||||||
|
return {
|
||||||
|
scale,
|
||||||
|
left: (tileW - crop.w * scale) / 2,
|
||||||
|
top: (tileH - crop.h * scale) / 2,
|
||||||
|
frameHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCropDrag(mode: XframeDragMode, start: XframeCrop, dxPage: number, dyPage: number): XframeCrop {
|
||||||
|
let left = start.x;
|
||||||
|
let top = start.y;
|
||||||
|
let right = start.x + start.w;
|
||||||
|
let bottom = start.y + start.h;
|
||||||
|
|
||||||
|
if (mode === 'move') {
|
||||||
|
left = Math.min(Math.max(0, left + dxPage), XFRAME_PAGE_WIDTH - start.w);
|
||||||
|
top = Math.min(Math.max(0, top + dyPage), Math.max(0, XFRAME_PREVIEW_PAGE_HEIGHT - start.h));
|
||||||
|
return clampXframeCrop({ x: left, y: top, w: start.w, h: start.h });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'nw' || mode === 'sw') {
|
||||||
|
left = Math.min(Math.max(0, left + dxPage), right - XFRAME_CROP_MIN_W);
|
||||||
|
} else {
|
||||||
|
right = Math.max(Math.min(XFRAME_PAGE_WIDTH, right + dxPage), left + XFRAME_CROP_MIN_W);
|
||||||
|
}
|
||||||
|
if (mode === 'nw' || mode === 'ne') {
|
||||||
|
top = Math.min(Math.max(0, top + dyPage), bottom - XFRAME_CROP_MIN_H);
|
||||||
|
} else {
|
||||||
|
bottom = Math.max(Math.min(XFRAME_PREVIEW_PAGE_HEIGHT, bottom + dyPage), top + XFRAME_CROP_MIN_H);
|
||||||
|
}
|
||||||
|
return clampXframeCrop({ x: left, y: top, w: right - left, h: bottom - top });
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ vi.mock('next-intl', () => ({
|
|||||||
useTranslations: () => (key: string) => key,
|
useTranslations: () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||||
import { XFRAME_SANDBOX } from './xframe-config';
|
import { XFRAME_SANDBOX } from './xframe-config';
|
||||||
import { XframeWidget } from './xframe-widget';
|
import { XframeWidget } from './xframe-widget';
|
||||||
|
|
||||||
@@ -34,11 +35,14 @@ function frame(): HTMLIFrameElement {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
// quick-260922-ge2: Kachelkoerper misst 640x360 (deterministische Geometrie)
|
||||||
|
stubResizeObserver({ width: 640, height: 360 });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('XframeWidget (quick-260921-qd3)', () => {
|
describe('XframeWidget (quick-260921-qd3)', () => {
|
||||||
@@ -151,3 +155,114 @@ describe('XframeWidget (quick-260921-qd3)', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
|
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-260922-ge2: Ausschnitt (Clip + verschobener, skalierter Rahmen bei
|
||||||
|
* gemessener Kachel 640x360), Zoom-Zweig (Prozentmasse + scale), 100 % wie
|
||||||
|
* heute, readOnly-Flaeche nur im Ansichtsmodus (T-GE2-02), Sandbox in jedem
|
||||||
|
* Zweig unveraendert (T-GE2-01).
|
||||||
|
*/
|
||||||
|
describe('XframeWidget — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
|
||||||
|
it('Test 13: Ausschnitt 0/0/1280/720 in 640x360 -> data-tile-size, Clip 640x360 bei 0/0, Rahmen 1280x720 scale(0.5), Sandbox unveraendert', () => {
|
||||||
|
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } });
|
||||||
|
const body = container.querySelector('[data-tile-size]');
|
||||||
|
expect(body?.getAttribute('data-tile-size')).toBe('640x360');
|
||||||
|
const clip = screen.getByTestId('xframe-crop-clip');
|
||||||
|
expect(clip.style.position).toBe('absolute');
|
||||||
|
expect(clip.style.left).toBe('0px');
|
||||||
|
expect(clip.style.top).toBe('0px');
|
||||||
|
expect(clip.style.width).toBe('640px');
|
||||||
|
expect(clip.style.height).toBe('360px');
|
||||||
|
expect(clip.style.overflow).toBe('hidden');
|
||||||
|
const f = frame();
|
||||||
|
expect(clip.contains(f)).toBe(true);
|
||||||
|
expect(f.style.left).toBe('0px');
|
||||||
|
expect(f.style.top).toBe('0px');
|
||||||
|
expect(f.style.width).toBe('1280px');
|
||||||
|
expect(f.style.height).toBe('720px');
|
||||||
|
expect(f.style.transform).toBe('scale(0.5)');
|
||||||
|
expect(f.style.transformOrigin).toBe('0 0');
|
||||||
|
expect(f.className).not.toContain('h-full');
|
||||||
|
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||||
|
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||||
|
expect(f.getAttribute('allow')).toBe('');
|
||||||
|
expect(f.getAttribute('loading')).toBe('lazy');
|
||||||
|
expect(f.getAttribute('data-reload-nonce')).toBe('0');
|
||||||
|
expect(f.getAttribute('src')).toBe(URL_A);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 14: Ausschnitt 200/100/800/400 in 640x360 -> Clip 640x320 bei 0/20, Rahmen um -160/-80 verschoben, scale(0.8)', () => {
|
||||||
|
renderWidget({ url: URL_A, crop: { x: 200, y: 100, w: 800, h: 400 } });
|
||||||
|
const clip = screen.getByTestId('xframe-crop-clip');
|
||||||
|
expect(clip.style.left).toBe('0px');
|
||||||
|
expect(clip.style.top).toBe('20px');
|
||||||
|
expect(clip.style.width).toBe('640px');
|
||||||
|
expect(clip.style.height).toBe('320px');
|
||||||
|
const f = frame();
|
||||||
|
expect(f.style.left).toBe('-160px');
|
||||||
|
expect(f.style.top).toBe('-80px');
|
||||||
|
expect(f.style.height).toBe('720px');
|
||||||
|
expect(f.style.transform).toBe('scale(0.8)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 15: zoom 60 ohne Ausschnitt -> kein Clip, Rahmen 166.67 % mit scale(0.6), absolut positioniert', () => {
|
||||||
|
renderWidget({ url: URL_A, zoom: 60 });
|
||||||
|
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
|
||||||
|
const f = frame();
|
||||||
|
expect(f.style.width).toBe('166.67%');
|
||||||
|
expect(f.style.height).toBe('166.67%');
|
||||||
|
expect(f.style.transform).toBe('scale(0.6)');
|
||||||
|
expect(f.style.transformOrigin).toBe('0 0');
|
||||||
|
expect(f.className).toContain('absolute');
|
||||||
|
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 16: zoom 100 und ohne zoom -> wie heute (kein style, h-full w-full, kein Clip); Ausschnitt + zoom 60 -> Zoom ignoriert', () => {
|
||||||
|
renderWidget({ url: URL_A, zoom: 100 });
|
||||||
|
expect(frame().style.transform).toBe('');
|
||||||
|
expect(frame().className).toContain('h-full');
|
||||||
|
expect(frame().className).toContain('w-full');
|
||||||
|
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
|
||||||
|
cleanup();
|
||||||
|
renderWidget({ url: URL_A });
|
||||||
|
expect(frame().style.transform).toBe('');
|
||||||
|
expect(frame().getAttribute('style')).toBeNull();
|
||||||
|
expect(frame().className).toContain('h-full');
|
||||||
|
cleanup();
|
||||||
|
renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, zoom: 60 });
|
||||||
|
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
|
||||||
|
expect(frame().style.transform).toBe('scale(0.5)');
|
||||||
|
expect(frame().style.transform).not.toBe('scale(0.6)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 17: readOnly im Ansichtsmodus -> Flaeche aria-hidden, absolute inset-0, NACH dem Rahmen; kein Edit-Overlay; Link z-10 bleibt', () => {
|
||||||
|
renderWidget({ url: URL_A, readOnly: true }, false);
|
||||||
|
const overlay = screen.getByTestId('xframe-readonly-overlay');
|
||||||
|
expect(overlay.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(overlay.className).toContain('absolute');
|
||||||
|
expect(overlay.className).toContain('inset-0');
|
||||||
|
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
|
||||||
|
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
||||||
|
expect(link.className).toContain('z-10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 18: readOnly im Bearbeitungsmodus -> nur Edit-Overlay; ohne readOnly im Ansichtsmodus -> keine Flaeche', () => {
|
||||||
|
renderWidget({ url: URL_A, readOnly: true }, true);
|
||||||
|
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
|
||||||
|
cleanup();
|
||||||
|
renderWidget({ url: URL_A }, false);
|
||||||
|
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 19: Ausschnitt im Bearbeitungsmodus -> Clip UND Edit-Overlay; mit Titel bleibt die Kopfleiste', () => {
|
||||||
|
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } }, true);
|
||||||
|
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
|
||||||
|
cleanup();
|
||||||
|
const second = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, title: 'Board' });
|
||||||
|
expect(second.container.querySelector('h2')).toHaveTextContent('Board');
|
||||||
|
expect(container.querySelector('h2')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
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
|
* 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“
|
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
|
||||||
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
||||||
* Rasters und startet im Bearbeitungsmodus kein Ziehen).
|
* 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) {
|
export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||||
const t = useTranslations('widgets');
|
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 [reloadNonce, setReloadNonce] = useState(0);
|
||||||
|
const [tile, setTile] = useState({ w: 0, h: 0 });
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasCrop = crop !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
||||||
@@ -50,6 +76,18 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [url, reloadSeconds, isEditMode]);
|
}, [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) {
|
if (url === null) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
<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 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 (
|
return (
|
||||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||||
@@ -75,22 +128,61 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="relative min-h-0 flex-1">
|
<div
|
||||||
<iframe
|
ref={bodyRef}
|
||||||
key={`${url}#${reloadNonce}`}
|
className="relative min-h-0 flex-1 overflow-hidden"
|
||||||
src={url}
|
data-tile-size={hasCrop ? `${Math.round(tile.w)}x${Math.round(tile.h)}` : undefined}
|
||||||
title={title !== '' ? title : url}
|
>
|
||||||
sandbox={XFRAME_SANDBOX}
|
{crop !== null && layout !== null ? (
|
||||||
allow=""
|
layout.scale > 0 && (
|
||||||
referrerPolicy="no-referrer"
|
<div
|
||||||
loading="lazy"
|
data-testid="xframe-crop-clip"
|
||||||
data-testid="xframe-frame"
|
style={{
|
||||||
data-reload-nonce={reloadNonce}
|
position: 'absolute',
|
||||||
className="h-full w-full border-0 bg-background"
|
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 && (
|
{isEditMode && (
|
||||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
|
<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 === '' && (
|
{title === '' && (
|
||||||
<NewTabLink
|
<NewTabLink
|
||||||
url={url}
|
url={url}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import de from '@/messages/de.json';
|
import de from '@/messages/de.json';
|
||||||
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* xframe-config-form.test — NEU (quick-260921-qd3). Texte aus der echten
|
* xframe-config-form.test — NEU (quick-260921-qd3). Texte aus der echten
|
||||||
@@ -22,17 +24,31 @@ import { XframeConfigForm } from './xframe-config-form';
|
|||||||
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
|
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
|
||||||
const URL_A = 'https://intern.example/board';
|
const URL_A = 'https://intern.example/board';
|
||||||
|
|
||||||
|
type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||||
|
type CropKey = 'x' | 'y' | 'w' | 'h';
|
||||||
|
|
||||||
function renderForm(config: Record<string, unknown>) {
|
function renderForm(config: Record<string, unknown>) {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(<XframeConfigForm config={config} onChange={onChange} />);
|
render(<XframeConfigForm config={config} onChange={onChange} />);
|
||||||
const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement;
|
const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement;
|
||||||
const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement;
|
const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement;
|
||||||
const reload = screen.getByLabelText(texts.reloadLabel) as HTMLSelectElement;
|
const reload = screen.getByLabelText(texts.reloadLabel) as HTMLSelectElement;
|
||||||
return { onChange, url, title, reload };
|
const cropEnable = screen.getByLabelText(texts.cropEnable) as HTMLInputElement;
|
||||||
|
const readOnly = screen.getByLabelText(texts.readOnly) as HTMLInputElement;
|
||||||
|
const rect = () => screen.getByTestId('xframe-crop-rect');
|
||||||
|
const handle = (corner: Corner) => screen.getByTestId(`xframe-crop-handle-${corner}`);
|
||||||
|
const numberField = (k: CropKey) => screen.getByLabelText(texts[`crop${k.toUpperCase()}`]) as HTMLInputElement;
|
||||||
|
return { onChange, url, title, reload, cropEnable, readOnly, rect, handle, numberField };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// quick-260922-ge2: Vorschau 640 px breit -> p = 0.5 (50 Bildschirm-px = 100 Seiten-px)
|
||||||
|
stubResizeObserver({ width: 640, height: 420 });
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('XframeConfigForm (quick-260921-qd3)', () => {
|
describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||||
@@ -139,3 +155,159 @@ describe('XframeConfigForm (quick-260921-qd3)', () => {
|
|||||||
expect(onChange).toHaveBeenCalledWith({ reloadSeconds: 600 });
|
expect(onChange).toHaveBeenCalledWith({ reloadSeconds: 600 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-260922-ge2: Ausschnitt (Checkbox -> EIN Aufruf mit Ausschnitt +
|
||||||
|
* readOnly), Vorschau mit derselben Sandbox und pointer-events none
|
||||||
|
* (T-GE2-01, T-GE2-03), Ziehen des Rahmens und der Ecken -> Entwurf folgt,
|
||||||
|
* EIN Aufruf beim Loslassen; Zahlenfelder klemmen; Zoom nur ohne
|
||||||
|
* Ausschnitt; „Nur anzeigen“; dauerhafter Hinweis.
|
||||||
|
*
|
||||||
|
* jsdom kennt kein setPointerCapture — Move/Up feuern auf DEMSELBEN
|
||||||
|
* Element; Griffe stoppen die Propagation, sonst zaehlt der Rahmen mit.
|
||||||
|
*/
|
||||||
|
const CROP = { x: 100, y: 100, w: 400, h: 300 };
|
||||||
|
|
||||||
|
describe('XframeConfigForm — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
|
||||||
|
it('Test 9: ohne Ausschnitt -> Checkbox aus, keine Vorschau, Zoom-Auswahl mit sieben Stufen; Einschalten -> EIN Aufruf mit Ausschnitt + readOnly', () => {
|
||||||
|
const { onChange, cropEnable } = renderForm({ url: URL_A });
|
||||||
|
expect(cropEnable.checked).toBe(false);
|
||||||
|
expect(screen.queryByTestId('xframe-crop-preview')).not.toBeInTheDocument();
|
||||||
|
const zoom = screen.getByLabelText(texts.zoomLabel) as HTMLSelectElement;
|
||||||
|
expect(Array.from(zoom.options).map((o) => o.value)).toEqual(['50', '60', '75', '90', '100', '125', '150']);
|
||||||
|
expect(Array.from(zoom.options).map((o) => o.textContent)).toEqual([
|
||||||
|
'50 %',
|
||||||
|
'60 %',
|
||||||
|
'75 %',
|
||||||
|
'90 %',
|
||||||
|
'100 %',
|
||||||
|
'125 %',
|
||||||
|
'150 %',
|
||||||
|
]);
|
||||||
|
expect(zoom.value).toBe('100');
|
||||||
|
fireEvent.click(cropEnable);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ crop: { x: 0, y: 0, w: 1280, h: 720 }, readOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 10: mit Ausschnitt -> Vorschau-Rahmen mit Sandbox, no-referrer, pointer-events none, 1280x3000 scale(0.5); Rahmen 50/50/200x150, vier Griffe, keine Zoom-Auswahl; Ausschalten -> { crop: null }', () => {
|
||||||
|
const { onChange, cropEnable, rect } = renderForm({ url: URL_A, crop: CROP });
|
||||||
|
expect(cropEnable.checked).toBe(true);
|
||||||
|
expect(screen.getByTestId('xframe-crop-preview')).toBeInTheDocument();
|
||||||
|
const preview = screen.getByTestId('xframe-crop-preview-frame') as HTMLIFrameElement;
|
||||||
|
expect(preview.getAttribute('src')).toBe(URL_A);
|
||||||
|
expect(preview.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||||
|
expect(preview.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||||
|
expect(preview.getAttribute('allow')).toBe('');
|
||||||
|
expect(preview.style.pointerEvents).toBe('none');
|
||||||
|
expect(preview.style.width).toBe('1280px');
|
||||||
|
expect(preview.style.height).toBe('3000px');
|
||||||
|
expect(preview.style.transform).toBe('scale(0.5)');
|
||||||
|
const r = rect();
|
||||||
|
// <fieldset> traegt die Gruppen-Rolle implizit (Biome useSemanticElements statt div role="group")
|
||||||
|
expect(r.tagName).toBe('FIELDSET');
|
||||||
|
expect(screen.getByRole('group', { name: texts.cropRectangle })).toBe(r);
|
||||||
|
expect(r.style.left).toBe('50px');
|
||||||
|
expect(r.style.top).toBe('50px');
|
||||||
|
expect(r.style.width).toBe('200px');
|
||||||
|
expect(r.style.height).toBe('150px');
|
||||||
|
for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
|
||||||
|
expect(screen.getByTestId(`xframe-crop-handle-${corner}`)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
expect(screen.queryByLabelText(texts.zoomLabel)).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(cropEnable);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ crop: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 11: Zahlenfelder — Breite 2000 + Blur klemmt auf 1280 (x -> 0); Hoehe 10 + Enter -> 60; "abc" und unveraendert -> kein Aufruf', () => {
|
||||||
|
const { onChange, numberField } = renderForm({ url: URL_A, crop: CROP });
|
||||||
|
expect(numberField('x').value).toBe('100');
|
||||||
|
expect(numberField('w').value).toBe('400');
|
||||||
|
fireEvent.change(numberField('w'), { target: { value: '2000' } });
|
||||||
|
fireEvent.blur(numberField('w'));
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ crop: { x: 0, y: 100, w: 1280, h: 300 } });
|
||||||
|
fireEvent.change(numberField('h'), { target: { value: '10' } });
|
||||||
|
fireEvent.keyDown(numberField('h'), { key: 'Enter' });
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ crop: { x: 100, y: 100, w: 400, h: 60 } });
|
||||||
|
fireEvent.change(numberField('x'), { target: { value: 'abc' } });
|
||||||
|
fireEvent.blur(numberField('x'));
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
fireEvent.change(numberField('x'), { target: { value: '100' } });
|
||||||
|
fireEvent.blur(numberField('x'));
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 12: Rahmen ziehen -> Entwurf folgt der Maus ohne Aufruf, Loslassen -> EIN Aufruf mit verschobenem Ausschnitt', () => {
|
||||||
|
const { onChange, rect } = renderForm({ url: URL_A, crop: CROP });
|
||||||
|
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||||
|
fireEvent.pointerMove(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(rect().style.left).toBe('100px');
|
||||||
|
expect(rect().style.top).toBe('75px');
|
||||||
|
fireEvent.pointerUp(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ crop: { x: 200, y: 150, w: 400, h: 300 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 13: Ecke unten rechts +50/+50 -> groesser, Gegenecke bleibt, genau ein Aufruf; Ecke oben links -50/-50 -> 0/0/500x400', () => {
|
||||||
|
const { onChange, handle } = renderForm({ url: URL_A, crop: CROP });
|
||||||
|
fireEvent.pointerDown(handle('se'), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||||
|
fireEvent.pointerMove(handle('se'), { clientX: 150, clientY: 150, pointerId: 1 });
|
||||||
|
fireEvent.pointerUp(handle('se'), { clientX: 150, clientY: 150, pointerId: 1 });
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ crop: { x: 100, y: 100, w: 500, h: 400 } });
|
||||||
|
cleanup();
|
||||||
|
const second = renderForm({ url: URL_A, crop: CROP });
|
||||||
|
fireEvent.pointerDown(second.handle('nw'), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||||
|
fireEvent.pointerMove(second.handle('nw'), { clientX: 50, clientY: 50, pointerId: 1 });
|
||||||
|
fireEvent.pointerUp(second.handle('nw'), { clientX: 50, clientY: 50, pointerId: 1 });
|
||||||
|
expect(second.onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second.onChange).toHaveBeenCalledWith({ crop: { x: 0, y: 0, w: 500, h: 400 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 14: Klick ohne Bewegung -> kein Aufruf; pointerCancel nach Bewegung -> kein Aufruf, Rahmen wieder bei 50px', () => {
|
||||||
|
const { onChange, rect } = renderForm({ url: URL_A, crop: CROP });
|
||||||
|
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||||
|
fireEvent.pointerUp(rect(), { clientX: 100, clientY: 100, pointerId: 1 });
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||||
|
fireEvent.pointerMove(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
|
||||||
|
expect(rect().style.left).toBe('100px');
|
||||||
|
fireEvent.pointerCancel(rect(), { pointerId: 1 });
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(rect().style.left).toBe('50px');
|
||||||
|
expect(rect().style.top).toBe('50px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 15: „Nur anzeigen“ — angehakt bei readOnly true, Klick -> { readOnly: false }; sonst aus, Klick -> { readOnly: true }; Hinweis immer sichtbar', () => {
|
||||||
|
const { onChange, readOnly } = renderForm({ url: URL_A, readOnly: true });
|
||||||
|
expect(readOnly.checked).toBe(true);
|
||||||
|
expect(screen.getByText(texts.cropHint)).toBeInTheDocument();
|
||||||
|
fireEvent.click(readOnly);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ readOnly: false });
|
||||||
|
cleanup();
|
||||||
|
const second = renderForm({ url: URL_A });
|
||||||
|
expect(second.readOnly.checked).toBe(false);
|
||||||
|
expect(screen.getByText(texts.cropHint)).toBeInTheDocument();
|
||||||
|
fireEvent.click(second.readOnly);
|
||||||
|
expect(second.onChange).toHaveBeenCalledWith({ readOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 16: Ausschnitt ohne Adresse -> Leerhinweis der Vorschau, kein Vorschau-Rahmen', () => {
|
||||||
|
renderForm({ crop: CROP });
|
||||||
|
expect(screen.getByTestId('xframe-crop-preview-empty')).toHaveTextContent(texts.cropPreviewEmpty);
|
||||||
|
expect(screen.queryByTestId('xframe-crop-preview-frame')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(texts.cropPreviewHint)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 17: Zoom-Auswahl 60 -> onChange({ zoom: 60 }) sofort', () => {
|
||||||
|
const { onChange } = renderForm({ url: URL_A });
|
||||||
|
fireEvent.change(screen.getByLabelText(texts.zoomLabel), { target: { value: '60' } });
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ zoom: 60 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type KeyboardEvent, useState } from 'react';
|
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
|
XFRAME_CROP_DEFAULT,
|
||||||
|
XFRAME_PAGE_WIDTH,
|
||||||
XFRAME_RELOAD_OPTIONS,
|
XFRAME_RELOAD_OPTIONS,
|
||||||
|
XFRAME_SANDBOX,
|
||||||
XFRAME_TITLE_MAX,
|
XFRAME_TITLE_MAX,
|
||||||
|
XFRAME_ZOOM_OPTIONS,
|
||||||
|
type XframeCrop,
|
||||||
|
clampXframeCrop,
|
||||||
isHttpsUrl,
|
isHttpsUrl,
|
||||||
resolveXframeConfig,
|
resolveXframeConfig,
|
||||||
} from '@/components/dashboard/widgets/xframe-config';
|
} from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
import {
|
||||||
|
XFRAME_PREVIEW_PAGE_HEIGHT,
|
||||||
|
type XframeDragMode,
|
||||||
|
applyCropDrag,
|
||||||
|
} from '@/components/dashboard/widgets/xframe-crop';
|
||||||
|
|
||||||
interface XframeConfigFormProps {
|
interface XframeConfigFormProps {
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
@@ -17,6 +28,9 @@ interface XframeConfigFormProps {
|
|||||||
const FIELD_CLASS =
|
const FIELD_CLASS =
|
||||||
'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground';
|
'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||||
const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
|
const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
|
||||||
|
const CHECKBOX_CLASS = 'h-4 w-4 rounded border-border text-primary';
|
||||||
|
/** Sichtbare Hoehe der Vorschau in Bildschirmpixeln (eigener Bildlauf darin). */
|
||||||
|
const PREVIEW_HEIGHT_PX = 420;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einstellungen des XFrame-Widgets im WidgetSettingsPanel
|
* Einstellungen des XFrame-Widgets im WidgetSettingsPanel
|
||||||
@@ -34,10 +48,22 @@ const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
|
|||||||
* Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht
|
* Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht
|
||||||
* dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig
|
* dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig
|
||||||
* erkennbar, die Kachel bietet dafuer immer „In neuem Tab öffnen“.
|
* erkennbar, die Kachel bietet dafuer immer „In neuem Tab öffnen“.
|
||||||
|
*
|
||||||
|
* Ausschnitt (quick-260922-ge2): die Vorschau ist DERSELBE <iframe> wie in
|
||||||
|
* der Kachel mit derselben Sandbox, `allow=""` und no-referrer (T-GE2-01),
|
||||||
|
* bei fester Seitenbreite 1280 auf die Vorschaubreite skaliert und mit
|
||||||
|
* `pointer-events: none` (T-GE2-03: die Vorschau ist nur zum Sehen —
|
||||||
|
* Zeigerereignisse gehen an Rahmen und Griffe, nie an die Fremdseite).
|
||||||
|
* Ziehen laeuft ueber Pointer-Events mit Capture (Wächter, weil jsdom kein
|
||||||
|
* `setPointerCapture` kennt), aus Deltas statt `getBoundingClientRect`
|
||||||
|
* (Bildlauf im Vorschaubehaelter stoert so nicht); der Entwurf bleibt lokal,
|
||||||
|
* beim Loslassen gibt es genau EIN `onChange` — und nur bei Aenderung.
|
||||||
|
* Der Ausschnitt ist eine Position auf der Seite, kein Inhalt: der Hinweis
|
||||||
|
* dazu steht dauerhaft unter den Feldern.
|
||||||
*/
|
*/
|
||||||
export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const { url, title, reloadSeconds } = resolveXframeConfig(config);
|
const { url, title, reloadSeconds, crop, zoom, readOnly } = resolveXframeConfig(config);
|
||||||
|
|
||||||
const [urlDraft, setUrlDraft] = useState(url ?? '');
|
const [urlDraft, setUrlDraft] = useState(url ?? '');
|
||||||
const [urlError, setUrlError] = useState(false);
|
const [urlError, setUrlError] = useState(false);
|
||||||
@@ -63,13 +89,6 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
|||||||
if (next !== title) onChange({ title: next });
|
if (next !== title) onChange({ title: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnter = (commit: () => void) => (e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
commit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadLabel = (seconds: number): string => {
|
const reloadLabel = (seconds: number): string => {
|
||||||
if (seconds === 0) return t('xframe.reloadOff');
|
if (seconds === 0) return t('xframe.reloadOff');
|
||||||
if (seconds === 60) return t('xframe.reloadMinute');
|
if (seconds === 60) return t('xframe.reloadMinute');
|
||||||
@@ -77,6 +96,17 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
|||||||
return t('xframe.reloadMinutes', { n: seconds / 60 });
|
return t('xframe.reloadMinutes', { n: seconds / 60 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Einschalten setzt Ausschnitt UND „Nur anzeigen“ in EINEM Aufruf (ein
|
||||||
|
// PATCH); Ausschalten laesst readOnly unberuehrt.
|
||||||
|
const toggleCrop = (checked: boolean) => {
|
||||||
|
if (checked) onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true });
|
||||||
|
else onChange({ crop: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitCrop = (next: XframeCrop) => {
|
||||||
|
if (crop === null || !sameCrop(next, crop)) onChange({ crop: next });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Adresse (nur https) */}
|
{/* Adresse (nur https) */}
|
||||||
@@ -143,6 +173,285 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ausschnitt ein/aus (quick-260922-ge2) */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="xframe-crop-enable"
|
||||||
|
type="checkbox"
|
||||||
|
className={CHECKBOX_CLASS}
|
||||||
|
checked={crop !== null}
|
||||||
|
onChange={(e) => toggleCrop(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="xframe-crop-enable" className="text-sm text-foreground">
|
||||||
|
{t('xframe.cropEnable')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{crop !== null ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('xframe.cropPreviewHint')}</p>
|
||||||
|
<CropPreview
|
||||||
|
url={url}
|
||||||
|
crop={crop}
|
||||||
|
onCommit={commitCrop}
|
||||||
|
rectangleLabel={t('xframe.cropRectangle')}
|
||||||
|
emptyText={t('xframe.cropPreviewEmpty')}
|
||||||
|
/>
|
||||||
|
{/* key: State-Reset, damit die Felder nach Ziehen/Speichern die neuen Werte zeigen */}
|
||||||
|
<CropNumberFields key={`${crop.x}-${crop.y}-${crop.w}-${crop.h}`} crop={crop} onCommit={commitCrop} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Vergroesserung nur fuer die ganze Seite */
|
||||||
|
<div>
|
||||||
|
<label htmlFor="xframe-zoom" className={LABEL_CLASS}>
|
||||||
|
{t('xframe.zoomLabel')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="xframe-zoom"
|
||||||
|
value={String(zoom)}
|
||||||
|
onChange={(e) => onChange({ zoom: Number(e.target.value) })}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
>
|
||||||
|
{XFRAME_ZOOM_OPTIONS.map((n) => (
|
||||||
|
<option key={n} value={String(n)}>
|
||||||
|
{t('xframe.zoomOption', { n })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* „Nur anzeigen“ — Bedienkomfort, keine Sicherheitsmassnahme (T-GE2-02) */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="xframe-readonly"
|
||||||
|
type="checkbox"
|
||||||
|
className={CHECKBOX_CLASS}
|
||||||
|
checked={readOnly}
|
||||||
|
onChange={(e) => onChange({ readOnly: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="xframe-readonly" className="text-sm text-foreground">
|
||||||
|
{t('xframe.readOnly')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="xframe-crop-hint" className="text-xs text-muted-foreground">
|
||||||
|
{t('xframe.cropHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter(commit: () => void) {
|
||||||
|
return (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameCrop(a: XframeCrop, b: XframeCrop): boolean {
|
||||||
|
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom (29.x) kennt PointerEvent, aber kein Element.setPointerCapture —
|
||||||
|
// im Browser wird gefangen, im Test feuern Move/Up auf demselben Element.
|
||||||
|
function capturePointer(el: HTMLElement, id: number) {
|
||||||
|
if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePointer(el: HTMLElement, id: number) {
|
||||||
|
if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
mode: XframeDragMode;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
start: XframeCrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDLE_CLASS: Record<Exclude<XframeDragMode, 'move'>, string> = {
|
||||||
|
nw: '-left-1.5 -top-1.5 cursor-nwse-resize',
|
||||||
|
ne: '-right-1.5 -top-1.5 cursor-nesw-resize',
|
||||||
|
sw: '-left-1.5 -bottom-1.5 cursor-nesw-resize',
|
||||||
|
se: '-right-1.5 -bottom-1.5 cursor-nwse-resize',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CropPreviewProps {
|
||||||
|
url: string | null;
|
||||||
|
crop: XframeCrop;
|
||||||
|
onCommit: (next: XframeCrop) => void;
|
||||||
|
rectangleLabel: string;
|
||||||
|
emptyText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau der Seite bei 1280 px Breite mit verschieb- und ziehbarem Rahmen. */
|
||||||
|
function CropPreview({ url, crop, onCommit, rectangleLabel, emptyText }: CropPreviewProps) {
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
const [draft, setDraft] = useState<XframeCrop | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = previewRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
setWidth(entries[0].contentRect.width);
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Massstab Bildschirm/Seite; vor der Messung 0.5 (640 px Vorschau).
|
||||||
|
const p = width > 0 ? width / XFRAME_PAGE_WIDTH : 0.5;
|
||||||
|
const c = draft ?? crop;
|
||||||
|
|
||||||
|
const dragProps = (mode: XframeDragMode) => ({
|
||||||
|
onPointerDown: (e: PointerEvent<HTMLElement>) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
// Griffe: sonst startet der Rahmen zusaetzlich ein Verschieben.
|
||||||
|
if (mode !== 'move') e.stopPropagation();
|
||||||
|
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: crop };
|
||||||
|
capturePointer(e.currentTarget, e.pointerId);
|
||||||
|
},
|
||||||
|
onPointerMove: (e: PointerEvent<HTMLElement>) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.mode !== mode) return;
|
||||||
|
setDraft(applyCropDrag(drag.mode, drag.start, (e.clientX - drag.startX) / p, (e.clientY - drag.startY) / p));
|
||||||
|
},
|
||||||
|
onPointerUp: (e: PointerEvent<HTMLElement>) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.mode !== mode) return;
|
||||||
|
releasePointer(e.currentTarget, e.pointerId);
|
||||||
|
dragRef.current = null;
|
||||||
|
const next = draft ?? drag.start;
|
||||||
|
setDraft(null);
|
||||||
|
onCommit(clampXframeCrop(next));
|
||||||
|
},
|
||||||
|
onPointerCancel: (e: PointerEvent<HTMLElement>) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.mode !== mode) return;
|
||||||
|
releasePointer(e.currentTarget, e.pointerId);
|
||||||
|
dragRef.current = null;
|
||||||
|
setDraft(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={previewRef}
|
||||||
|
data-testid="xframe-crop-preview"
|
||||||
|
className="relative overflow-y-auto rounded border border-border bg-background"
|
||||||
|
style={{ height: PREVIEW_HEIGHT_PX }}
|
||||||
|
>
|
||||||
|
{url === null ? (
|
||||||
|
<p data-testid="xframe-crop-preview-empty" className="p-3 text-sm text-muted-foreground">
|
||||||
|
{emptyText}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-testid="xframe-crop-stage"
|
||||||
|
className="relative"
|
||||||
|
style={{ width: XFRAME_PAGE_WIDTH * p, height: XFRAME_PREVIEW_PAGE_HEIGHT * p }}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
data-testid="xframe-crop-preview-frame"
|
||||||
|
src={url}
|
||||||
|
title={rectangleLabel}
|
||||||
|
sandbox={XFRAME_SANDBOX}
|
||||||
|
allow=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
className="border-0"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: XFRAME_PAGE_WIDTH,
|
||||||
|
height: XFRAME_PREVIEW_PAGE_HEIGHT,
|
||||||
|
transform: `scale(${p})`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* <fieldset> statt div role="group" (Biome useSemanticElements): implizite
|
||||||
|
Gruppen-Rolle, Name per aria-label; Preflight nimmt Rand und Innenabstand. */}
|
||||||
|
<fieldset
|
||||||
|
aria-label={rectangleLabel}
|
||||||
|
data-testid="xframe-crop-rect"
|
||||||
|
className="absolute m-0 min-w-0 cursor-move touch-none border-2 border-primary p-0"
|
||||||
|
style={{ left: c.x * p, top: c.y * p, width: c.w * p, height: c.h * p }}
|
||||||
|
{...dragProps('move')}
|
||||||
|
>
|
||||||
|
{(Object.keys(HANDLE_CLASS) as Array<keyof typeof HANDLE_CLASS>).map((corner) => (
|
||||||
|
<div
|
||||||
|
key={corner}
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid={`xframe-crop-handle-${corner}`}
|
||||||
|
className={`absolute h-3 w-3 touch-none bg-primary ${HANDLE_CLASS[corner]}`}
|
||||||
|
{...dragProps(corner)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CROP_KEYS: Array<keyof XframeCrop> = ['x', 'y', 'w', 'h'];
|
||||||
|
const CROP_LABEL_KEYS: Record<keyof XframeCrop, string> = {
|
||||||
|
x: 'xframe.cropX',
|
||||||
|
y: 'xframe.cropY',
|
||||||
|
w: 'xframe.cropW',
|
||||||
|
h: 'xframe.cropH',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Links/Oben/Breite/Hoehe als Zahlenfelder; Uebernahme bei Blur/Enter, geklemmt. */
|
||||||
|
function CropNumberFields({ crop, onCommit }: { crop: XframeCrop; onCommit: (next: XframeCrop) => void }) {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
const [drafts, setDrafts] = useState<Record<keyof XframeCrop, string>>({
|
||||||
|
x: String(crop.x),
|
||||||
|
y: String(crop.y),
|
||||||
|
w: String(crop.w),
|
||||||
|
h: String(crop.h),
|
||||||
|
});
|
||||||
|
|
||||||
|
const commit = (k: keyof XframeCrop) => {
|
||||||
|
const n = Number(drafts[k]);
|
||||||
|
if (drafts[k].trim() === '' || !Number.isFinite(n)) return;
|
||||||
|
onCommit(clampXframeCrop({ ...crop, [k]: n }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{CROP_KEYS.map((k) => (
|
||||||
|
<div key={k}>
|
||||||
|
<label htmlFor={`xframe-crop-${k}`} className={LABEL_CLASS}>
|
||||||
|
{t(CROP_LABEL_KEYS[k])}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`xframe-crop-${k}`}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
value={drafts[k]}
|
||||||
|
onChange={(e) => setDrafts((d) => ({ ...d, [k]: e.target.value }))}
|
||||||
|
onBlur={() => commit(k)}
|
||||||
|
onKeyDown={onEnter(() => commit(k))}
|
||||||
|
aria-describedby="xframe-crop-unit-hint"
|
||||||
|
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p id="xframe-crop-unit-hint" className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t('xframe.cropUnitHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,7 +382,20 @@
|
|||||||
"reloadMinute": "Jede Minute",
|
"reloadMinute": "Jede Minute",
|
||||||
"reloadMinutes": "Alle {n} Minuten",
|
"reloadMinutes": "Alle {n} Minuten",
|
||||||
"reloadHour": "Jede Stunde",
|
"reloadHour": "Jede Stunde",
|
||||||
"embedHint": "Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem."
|
"embedHint": "Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem.",
|
||||||
|
"cropEnable": "Nur einen Ausschnitt der Seite anzeigen",
|
||||||
|
"cropHint": "Der Ausschnitt ist eine Position auf der Seite. Ändert die Seite ihren Aufbau, kann der Ausschnitt verrutschen und muss neu gesetzt werden.",
|
||||||
|
"cropPreviewHint": "Vorschau der Seite bei 1280 Pixel Breite. Ziehen Sie den Rahmen an die gewünschte Stelle; an den Ecken ändern Sie seine Größe. Nach unten scrollen zeigt mehr von der Seite.",
|
||||||
|
"cropPreviewEmpty": "Die Vorschau erscheint, sobald eine Adresse eingetragen ist.",
|
||||||
|
"cropRectangle": "Ausschnitt – ziehen zum Verschieben, Ecken zum Ändern der Größe",
|
||||||
|
"cropX": "Links",
|
||||||
|
"cropY": "Oben",
|
||||||
|
"cropW": "Breite",
|
||||||
|
"cropH": "Höhe",
|
||||||
|
"cropUnitHint": "Werte in Pixeln der Seite bei 1280 Pixel Breite",
|
||||||
|
"zoomLabel": "Vergrößerung der ganzen Seite",
|
||||||
|
"zoomOption": "{n} %",
|
||||||
|
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -382,7 +382,20 @@
|
|||||||
"reloadMinute": "Every minute",
|
"reloadMinute": "Every minute",
|
||||||
"reloadMinutes": "Every {n} minutes",
|
"reloadMinutes": "Every {n} minutes",
|
||||||
"reloadHour": "Every hour",
|
"reloadHour": "Every hour",
|
||||||
"embedHint": "Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page."
|
"embedHint": "Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page.",
|
||||||
|
"cropEnable": "Show only a section of the page",
|
||||||
|
"cropHint": "The section is a position on the page. If the page changes its layout, the section may shift and has to be set again.",
|
||||||
|
"cropPreviewHint": "Preview of the page at 1280 pixels wide. Drag the frame to the desired spot; use the corners to change its size. Scroll down to see more of the page.",
|
||||||
|
"cropPreviewEmpty": "The preview appears once an address is set.",
|
||||||
|
"cropRectangle": "Section – drag to move, corners to resize",
|
||||||
|
"cropX": "Left",
|
||||||
|
"cropY": "Top",
|
||||||
|
"cropW": "Width",
|
||||||
|
"cropH": "Height",
|
||||||
|
"cropUnitHint": "Values in page pixels at 1280 pixels wide",
|
||||||
|
"zoomLabel": "Zoom of the full page",
|
||||||
|
"zoomOption": "{n} %",
|
||||||
|
"readOnly": "View only – block clicks and scrolling inside the frame"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'Bildausschnitt',
|
'Bildausschnitt',
|
||||||
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
|
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
|
||||||
'neuem',
|
'neuem',
|
||||||
|
// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“
|
||||||
|
'Ausschnitt',
|
||||||
'Absenderadresse',
|
'Absenderadresse',
|
||||||
'muss',
|
'muss',
|
||||||
'musste',
|
'musste',
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fake-resize-observer — file-scoped ResizeObserver stub with an explicit
|
||||||
|
* size (quick-260922-ge2).
|
||||||
|
*
|
||||||
|
* setup.ts already polyfills ResizeObserver globally for jsdom, but it
|
||||||
|
* always reports 1200x800. Components whose geometry depends on the
|
||||||
|
* measured size (XFrame crop clip, crop preview) need deterministic,
|
||||||
|
* test-chosen numbers — so a test file overrides the global with this
|
||||||
|
* stub and restores it via `vi.unstubAllGlobals()` in `afterEach`.
|
||||||
|
*
|
||||||
|
* The stub class is structurally a ResizeObserver (observe / unobserve /
|
||||||
|
* disconnect); the single `as ResizeObserverEntry` cast is enough because
|
||||||
|
* jsdom provides `DOMRectReadOnly` and the callback only reads
|
||||||
|
* `contentRect`; no double cast through `unknown` is needed.
|
||||||
|
*/
|
||||||
|
export function stubResizeObserver(size: { width: number; height: number }): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'ResizeObserver',
|
||||||
|
class {
|
||||||
|
private readonly cb: ResizeObserverCallback;
|
||||||
|
constructor(cb: ResizeObserverCallback) {
|
||||||
|
this.cb = cb;
|
||||||
|
}
|
||||||
|
observe() {
|
||||||
|
this.cb([{ contentRect: new DOMRectReadOnly(0, 0, size.width, size.height) } as ResizeObserverEntry], this);
|
||||||
|
}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user