diff --git a/apps/web/src/components/dashboard/widgets/xframe-config.test.ts b/apps/web/src/components/dashboard/widgets/xframe-config.test.ts index 69c1985..d2476f8 100644 --- a/apps/web/src/components/dashboard/widgets/xframe-config.test.ts +++ b/apps/web/src/components/dashboard/widgets/xframe-config.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + XFRAME_CROP_DEFAULT, + XFRAME_PAGE_WIDTH, XFRAME_RELOAD_MAX, XFRAME_RELOAD_MIN, XFRAME_RELOAD_OPTIONS, XFRAME_SANDBOX, XFRAME_TITLE_MAX, + XFRAME_ZOOM_OPTIONS, + clampXframeCrop, isHttpsUrl, resolveXframeConfig, } from './xframe-config'; @@ -19,7 +23,14 @@ import { */ describe('resolveXframeConfig (quick-260921-qd3)', () => { 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)', () => { @@ -64,7 +75,101 @@ describe('resolveXframeConfig (quick-260921-qd3)', () => { it('Test 8: alle drei Felder zusammen', () => { expect( 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 }); }); }); diff --git a/apps/web/src/components/dashboard/widgets/xframe-config.ts b/apps/web/src/components/dashboard/widgets/xframe-config.ts index bc892cf..02ad933 100644 --- a/apps/web/src/components/dashboard/widgets/xframe-config.ts +++ b/apps/web/src/components/dashboard/widgets/xframe-config.ts @@ -41,12 +41,50 @@ export const XFRAME_SANDBOX = // 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 { @@ -75,10 +113,44 @@ function resolveReload(raw: unknown): number { 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 { + 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): 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), }; } diff --git a/apps/web/src/components/dashboard/widgets/xframe-crop.test.ts b/apps/web/src/components/dashboard/widgets/xframe-crop.test.ts new file mode 100644 index 0000000..fe427f3 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/xframe-crop.test.ts @@ -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 }); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/xframe-crop.ts b/apps/web/src/components/dashboard/widgets/xframe-crop.ts new file mode 100644 index 0000000..904af26 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/xframe-crop.ts @@ -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 }); +} diff --git a/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx b/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx index e3ff038..cac2160 100644 --- a/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx @@ -6,6 +6,7 @@ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, })); +import { stubResizeObserver } from '@/test/fake-resize-observer'; import { XFRAME_SANDBOX } from './xframe-config'; import { XframeWidget } from './xframe-widget'; @@ -34,11 +35,14 @@ function frame(): HTMLIFrameElement { beforeEach(() => { vi.useFakeTimers(); + // quick-260922-ge2: Kachelkoerper misst 640x360 (deterministische Geometrie) + stubResizeObserver({ width: 640, height: 360 }); }); afterEach(() => { cleanup(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); 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'); }); }); + +/** + * 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(); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/xframe-widget.tsx b/apps/web/src/components/dashboard/widgets/xframe-widget.tsx index c178a5c..783395d 100644 --- a/apps/web/src/components/dashboard/widgets/xframe-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/xframe-widget.tsx @@ -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