fix(desktop): Links mit target=_blank im Client ueber window.open oeffnen
Im Desktop-Client unter Windows tat ein Klick auf Favoriten und andere Links mit target=_blank nichts, waehrend window.open (Such-Widget) ueber on_new_window im System-Browser landete (VM 8233 nachgestellt). DesktopExternalLinks leitet im Client Links- und Mittelklicks auf solche http/https-Links auf window.open um; von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
quick_id: 261001-cxo
|
||||
description: "Desktop-Client: Links mit target=_blank oeffnen"
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Desktop-Client: Links mit target=_blank oeffnen
|
||||
|
||||
**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (`<a target="_blank">`) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht.
|
||||
|
||||
## Task 1 — DesktopExternalLinks
|
||||
- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert.
|
||||
- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen.
|
||||
- Test `desktop-external-links.test.tsx`.
|
||||
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||
|
||||
**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge.
|
||||
@@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
|
||||
|
||||
## 1.9.0 – 2026-09-30
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
||||
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
|
||||
description: 'Modulare Workflow-Plattform',
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
@@ -29,6 +26,7 @@ export default async function RootLayout({
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<DesktopContextMenuGuard />
|
||||
<DesktopExternalLinks />
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client';
|
||||
import { DesktopExternalLinks } from './desktop-external-links';
|
||||
|
||||
/**
|
||||
* desktop-external-links.test (quick-261001-cxo) — im Desktop-Client gehen
|
||||
* Klicks auf Links mit `target="_blank"` ueber `window.open`; ohne Cookie,
|
||||
* bei verhinderten Klicks, ohne `_blank` und bei Nicht-http(s) bleibt alles
|
||||
* unberuehrt. `fire` gibt den `dispatchEvent`-Rueckgabewert zurueck:
|
||||
* `false` bedeutet, `preventDefault()` wurde aufgerufen.
|
||||
*/
|
||||
function setCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
|
||||
}
|
||||
|
||||
function clearCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
|
||||
function link(href: string, target?: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
if (target) a.target = target;
|
||||
const span = document.createElement('span');
|
||||
a.appendChild(span);
|
||||
document.body.appendChild(a);
|
||||
return span;
|
||||
}
|
||||
|
||||
function fire(el: Element, type: 'click' | 'auxclick' = 'click', button = 0) {
|
||||
return el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button }));
|
||||
}
|
||||
|
||||
let openSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearCookie();
|
||||
openSpy.mockRestore();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('DesktopExternalLinks', () => {
|
||||
it('mit Cookie: Klick auf _blank-Link oeffnet per window.open', async () => {
|
||||
setCookie();
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
const inner = link('https://chatgpt.com/', '_blank');
|
||||
expect(fire(inner)).toBe(false);
|
||||
expect(openSpy).toHaveBeenCalledWith('https://chatgpt.com/', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('mit Cookie: Mittelklick auf _blank-Link oeffnet ebenfalls', async () => {
|
||||
setCookie();
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
const inner = link('http://intranet.local/', '_blank');
|
||||
expect(fire(inner, 'auxclick', 1)).toBe(false);
|
||||
expect(openSpy).toHaveBeenCalledWith('http://intranet.local/', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('mit Cookie: verhinderter Klick, Link ohne _blank und mailto bleiben unberuehrt', async () => {
|
||||
setCookie();
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
const prevented = link('https://chatgpt.com/', '_blank');
|
||||
prevented.addEventListener('click', (e) => e.preventDefault());
|
||||
fire(prevented);
|
||||
|
||||
expect(fire(link('https://example.com/'))).toBe(true);
|
||||
expect(fire(link('mailto:a@example.com', '_blank'))).toBe(true);
|
||||
expect(fire(link('https://example.com/', '_blank'), 'click', 2)).toBe(true);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Cookie: keine Umleitung', async () => {
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(fire(link('https://chatgpt.com/', '_blank'))).toBe(true);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIsDesktopClient } from '@/lib/desktop-client';
|
||||
|
||||
/**
|
||||
* DesktopExternalLinks (quick-261001-cxo) — Links mit `target="_blank"`
|
||||
* (Favoriten, „In neuem Tab öffnen“ bei XFrame/eigenen Modulen, Quellen im
|
||||
* Ausschreibungs-Radar) taten im Desktop-Client unter Windows beim Klick
|
||||
* nichts, `window.open` (Such-Widget) dagegen landet ueber `on_new_window`
|
||||
* im System-Browser (VM 8233, 01.10.2026: Favorit ohne Wirkung, Suche oeffnet
|
||||
* Edge). Im Client leitet dieser Helfer solche Klicks deshalb auf
|
||||
* `window.open` um.
|
||||
*
|
||||
* Lauscht auf `window` in der Bubble-Phase, also NACH Reacts Handlern am
|
||||
* Dokument: ein Klick, den die Seite selbst verhindert (Favoriten im
|
||||
* Bearbeiten-Modus), bleibt verhindert. Nur http/https -- alles andere
|
||||
* verwirft der Client ohnehin.
|
||||
*/
|
||||
function externalTarget(event: MouseEvent): HTMLAnchorElement | null {
|
||||
if (event.defaultPrevented) return null;
|
||||
// Linksklick und Mittelklick oeffnen beide ein neues Fenster.
|
||||
if (event.button !== 0 && event.button !== 1) return null;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return null;
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return null;
|
||||
if (anchor.target !== '_blank') return null;
|
||||
if (anchor.protocol !== 'http:' && anchor.protocol !== 'https:') return null;
|
||||
return anchor;
|
||||
}
|
||||
|
||||
export function DesktopExternalLinks() {
|
||||
const isDesktop = useIsDesktopClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (event.type === 'auxclick' && event.button !== 1) return;
|
||||
if (event.type === 'click' && event.button !== 0) return;
|
||||
const anchor = externalTarget(event);
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
window.addEventListener('click', handler);
|
||||
window.addEventListener('auxclick', handler);
|
||||
return () => {
|
||||
window.removeEventListener('click', handler);
|
||||
window.removeEventListener('auxclick', handler);
|
||||
};
|
||||
}, [isDesktop]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user