mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(web): add alert when pdf export popup is blocked (#664)
Fixes PDF export feedback when popup blockers prevent opening the export preview.
This commit is contained in:
parent
2bbd677ea2
commit
555dbebfe2
5 changed files with 75 additions and 27 deletions
|
|
@ -243,10 +243,16 @@ export function App() {
|
|||
setTemplates(list);
|
||||
}, []);
|
||||
|
||||
const handleConfigSave = useCallback((next: AppConfig) => {
|
||||
// Saving from any settings dialog (welcome or regular) counts as
|
||||
// having completed onboarding — the user has actively chosen a
|
||||
// configuration, so future page loads can skip the auto-popup.
|
||||
const handleConfigSave = useCallback(async (next: AppConfig, closeModal: boolean = true) => {
|
||||
// Only sync Composio key to the daemon when it actually changed,
|
||||
// so unrelated saves (theme, model, etc.) are never blocked.
|
||||
const composioChanged =
|
||||
next.composio?.apiKey !== config.composio?.apiKey ||
|
||||
next.composio?.apiKeyConfigured !== config.composio?.apiKeyConfigured;
|
||||
if (composioChanged) {
|
||||
const ok = await syncComposioConfigToDaemon(next.composio);
|
||||
if (!ok) return { success: false };
|
||||
}
|
||||
const withOnboarding: AppConfig = {
|
||||
...next,
|
||||
composio: normalizeSavedComposioConfig(next.composio),
|
||||
|
|
@ -257,12 +263,10 @@ export function App() {
|
|||
force: true,
|
||||
});
|
||||
void syncConfigToDaemon(withOnboarding);
|
||||
// Keep the Composio secret out of localStorage, but send the raw pending
|
||||
// edit to the daemon before it is normalized away for local persistence.
|
||||
void syncComposioConfigToDaemon(next.composio);
|
||||
setConfig(withOnboarding);
|
||||
setSettingsOpen(false);
|
||||
}, []);
|
||||
if (closeModal) setSettingsOpen(false);
|
||||
return { success: true };
|
||||
}, [config]);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: AppConfig['mode']) => {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ interface Props {
|
|||
appVersionInfo: AppVersionInfo | null;
|
||||
welcome?: boolean;
|
||||
initialSection?: SettingsSection;
|
||||
onSave: (cfg: AppConfig) => void;
|
||||
onSave: (cfg: AppConfig, closeModal?: boolean) => Promise<{ success: boolean }> | void;
|
||||
onClose: () => void;
|
||||
onRefreshAgents: (
|
||||
options?: AgentRefreshOptions,
|
||||
|
|
@ -1686,7 +1686,7 @@ export function SettingsDialog({
|
|||
type="button"
|
||||
className="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => onSave(cfg)}
|
||||
onClick={() => onSave(cfg, activeSection !== 'composio')}
|
||||
>
|
||||
{welcome ? t('settings.getStarted') : t('common.save')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -236,14 +236,31 @@ export function exportAsPdf(
|
|||
}
|
||||
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const win = window.open(url, '_blank', sandboxedPreview ? 'noopener,noreferrer' : undefined);
|
||||
|
||||
// Open an empty tab synchronously (without noopener) to reliably detect popup blocking.
|
||||
// Since window.open with 'noopener' returns null on success by specification,
|
||||
// this approach allows us to distinguish between a successful export and a blocked popup.
|
||||
const win = window.open('', '_blank');
|
||||
|
||||
if (!win) {
|
||||
// Popup blocked — at least the tab navigation may have happened above.
|
||||
// Nothing else we can do without a fresh user gesture.
|
||||
if (typeof alert !== 'undefined') {
|
||||
alert('Popup blocked! Please allow popups for this site to export as PDF.');
|
||||
}
|
||||
URL.revokeObjectURL(url); // Prevent memory leaks on early exit
|
||||
return;
|
||||
}
|
||||
// Revoke later — the loaded document keeps a reference until the tab
|
||||
// closes; revoking the URL string only removes the lookup name.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
|
||||
if (sandboxedPreview) {
|
||||
try {
|
||||
// Disassociate the opener reference to preserve sandboxing/noopener behavior
|
||||
win.opener = null;
|
||||
} catch (e) {
|
||||
// Guard against potential context environment restrictions
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate the verified window to the generated Blob URL
|
||||
win.location.href = url;
|
||||
}
|
||||
|
||||
function injectPrintScript(doc: string, title: string): string {
|
||||
|
|
|
|||
|
|
@ -294,19 +294,20 @@ export async function fetchComposioConfigFromDaemon(): Promise<AppConfig['compos
|
|||
|
||||
export async function syncComposioConfigToDaemon(
|
||||
config: AppConfig['composio'] | undefined,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const apiKey = config?.apiKey ?? '';
|
||||
const payload = {
|
||||
...(apiKey.trim() || !config?.apiKeyConfigured ? { apiKey } : {}),
|
||||
};
|
||||
try {
|
||||
await fetch('/api/connectors/composio/config', {
|
||||
const response = await fetch('/api/connectors/composio/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
// Daemon offline; localStorage keeps the user's copy for the next save.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,24 +145,30 @@ describe('exportAsMd', () => {
|
|||
describe('sandboxed preview Blob exports', () => {
|
||||
let capturedBlob: Blob | undefined;
|
||||
let openedFeatures: string | undefined;
|
||||
let mockWin: { opener: unknown; location: { href: string } };
|
||||
let openCalls: string[][];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedBlob = undefined;
|
||||
openedFeatures = undefined;
|
||||
openCalls = [];
|
||||
mockWin = { opener: {}, location: { href: '' } };
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: (blob: Blob) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:test';
|
||||
},
|
||||
revokeObjectURL: () => {},
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
open: (_url: string, _target: string, features?: string) => {
|
||||
openCalls.push([_url, _target]);
|
||||
openedFeatures = features;
|
||||
return null;
|
||||
return mockWin;
|
||||
},
|
||||
addEventListener: () => {},
|
||||
});
|
||||
vi.stubGlobal('alert', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -206,10 +212,12 @@ describe('sandboxed preview Blob exports', () => {
|
|||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
});
|
||||
|
||||
it('uses a sandboxed noopener Blob wrapper by default for PDF exports', async () => {
|
||||
it('uses a sandboxed Blob wrapper with synchronous popup detection for PDF exports', async () => {
|
||||
exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(openCalls).toEqual([['', '_blank']]);
|
||||
expect(mockWin.opener).toBeNull();
|
||||
expect(mockWin.location.href).toBe('blob:test');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
|
|
@ -221,11 +229,12 @@ describe('sandboxed preview Blob exports', () => {
|
|||
it('preserves deck print handling inside sandboxed PDF exports', async () => {
|
||||
exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(openCalls).toEqual([['', '_blank']]);
|
||||
expect(mockWin.opener).toBeNull();
|
||||
expect(mockWin.location.href).toBe('blob:test');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('data-deck-print="injected"');
|
||||
expect(wrapper).toContain('page-break-after: always;');
|
||||
});
|
||||
|
|
@ -235,10 +244,27 @@ describe('sandboxed preview Blob exports', () => {
|
|||
sandboxedPreview: false,
|
||||
});
|
||||
|
||||
expect(openedFeatures).toBeUndefined();
|
||||
expect(openCalls).toEqual([['', '_blank']]);
|
||||
expect(mockWin.opener).toEqual({});
|
||||
expect(mockWin.location.href).toBe('blob:test');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const doc = await capturedBlob!.text();
|
||||
expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(doc).toContain('<main>Trusted local document</main>');
|
||||
});
|
||||
|
||||
it('shows an alert and revokes the blob URL when the popup is blocked', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
open: () => null,
|
||||
});
|
||||
|
||||
const revokeSpy = URL.revokeObjectURL as ReturnType<typeof vi.fn>;
|
||||
revokeSpy.mockClear();
|
||||
|
||||
exportAsPdf('<p>test</p>', 'Blocked');
|
||||
|
||||
expect(alert).toHaveBeenCalledWith('Popup blocked! Please allow popups for this site to export as PDF.');
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue