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,6 +1,8 @@
|
||||
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 { 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
|
||||
@@ -22,17 +24,31 @@ import { XframeConfigForm } from './xframe-config-form';
|
||||
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
|
||||
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>) {
|
||||
const onChange = vi.fn();
|
||||
render(<XframeConfigForm config={config} onChange={onChange} />);
|
||||
const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement;
|
||||
const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement;
|
||||
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(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||
@@ -139,3 +155,159 @@ describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||
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';
|
||||
|
||||
import { type KeyboardEvent, useState } from 'react';
|
||||
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
XFRAME_CROP_DEFAULT,
|
||||
XFRAME_PAGE_WIDTH,
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_SANDBOX,
|
||||
XFRAME_TITLE_MAX,
|
||||
XFRAME_ZOOM_OPTIONS,
|
||||
type XframeCrop,
|
||||
clampXframeCrop,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from '@/components/dashboard/widgets/xframe-config';
|
||||
import {
|
||||
XFRAME_PREVIEW_PAGE_HEIGHT,
|
||||
type XframeDragMode,
|
||||
applyCropDrag,
|
||||
} from '@/components/dashboard/widgets/xframe-crop';
|
||||
|
||||
interface XframeConfigFormProps {
|
||||
config: Record<string, unknown>;
|
||||
@@ -17,6 +28,9 @@ interface XframeConfigFormProps {
|
||||
const FIELD_CLASS =
|
||||
'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 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
|
||||
@@ -34,10 +48,22 @@ const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
|
||||
* Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht
|
||||
* dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig
|
||||
* 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) {
|
||||
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 [urlError, setUrlError] = useState(false);
|
||||
@@ -63,13 +89,6 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
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 => {
|
||||
if (seconds === 0) return t('xframe.reloadOff');
|
||||
if (seconds === 60) return t('xframe.reloadMinute');
|
||||
@@ -77,6 +96,17 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Adresse (nur https) */}
|
||||
@@ -143,6 +173,285 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user